Compare commits

...
5 Commits
41 changed files with 1504 additions and 432 deletions

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
# Server Configuration
export ADDR=":8080"
export EXTERNAL_URL="localhost:8080"
export FRONTEND_URL="http://localhost:5173"
export ENV="development"
export ALLOWED_ORIGINS="https://*,http://*" # TODO: Change to the actual allowed origins on production
# Database Configuration
export DB_ADDR="postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"
export DB_MAX_OPEN_CONNS="30"
export DB_MAX_IDLE_CONNS="30"
export DB_MAX_IDLE_TIME="15m"
# Redis Configuration
export REDIS_ADDR="localhost:6379"
export REDIS_PASSWORD=""
export REDIS_DB="0"
export REDIS_ENABLED="false"
# Authentication
export BASIC_AUTH_USERNAME="admin"
export BASIC_AUTH_PASSWORD="admin"
export AUTH_TOKEN_SECRET="your-secret-key-here"
export AUTH_TOKEN_EXP="24h"
export AUTH_TOKEN_ISS="lkapi"
export AUTH_TOKEN_AUD="lkapi"
# Email Configuration
export FROM_EMAIL="[email protected]"
export SENDGRID_API_KEY="your-sendgrid-api-key"
# Rate Limiter
export RATE_LIMITER_REQS_COUNT="20"
export RATE_LIMITER_TIME_FRAME="1m"
export RATE_LIMITER_ENABLED="true"
+1
View File
@@ -1 +1,2 @@
.envrc .envrc
/bin/
+1 -1
View File
@@ -27,7 +27,7 @@ migrate-down:
.PHONY: seed .PHONY: seed
seed: seed:
@go run cmd/migrate/seed/main.go @DB_ADDR=$(DB_ADDR) go run cmd/migrate/seed/main.go
.PHONY: gen-docs .PHONY: gen-docs
gen-docs: gen-docs:
+1 -1
View File
@@ -1 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated
BIN
View File
Binary file not shown.
+27 -11
View File
@@ -48,7 +48,8 @@ type config struct {
} }
type mailConfig struct { type mailConfig struct {
exp time.Duration inviteExp time.Duration
passwordRefreshExp time.Duration
fromEmail string fromEmail string
sendGrid sendGridConfig sendGrid sendGridConfig
} }
@@ -66,6 +67,7 @@ type basicConfig struct {
type tokenConfig struct { type tokenConfig struct {
secret string secret string
exp time.Duration exp time.Duration
refreshExp time.Duration
iss string iss string
aud string aud string
} }
@@ -89,6 +91,8 @@ type dbConfig struct {
maxIdleTime time.Duration maxIdleTime time.Duration
} }
// This `mount` function is responsible for setting up the routing for your API
// using the Chi router. Here's a breakdown of what it does:
func (api *api) mount() http.Handler { func (api *api) mount() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// *CORS // *CORS
@@ -106,7 +110,7 @@ func (api *api) mount() http.Handler {
AllowCredentials: false, AllowCredentials: false,
MaxAge: 300, MaxAge: 300,
})) }))
r.Use(api.RateLimiterMiddleware) r.Use(api.rateLimiterMiddleware)
r.Use(middleware.Timeout(60 * time.Second)) r.Use(middleware.Timeout(60 * time.Second))
@@ -135,26 +139,32 @@ func (api *api) mount() http.Handler {
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
r.Put("/activate/{token}", api.activateUserHandler) r.Put("/activate/{token}", api.activateUserHandler)
r.Put("/reset-password/{token}", api.resetPasswordHandler)
r.Route("/{id}", func(r chi.Router) { r.Route("/me", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware) r.Use(api.AuthTokenMiddleware)
r.Get("/", api.getUserHandler) r.Get("/", api.getUserHandler)
r.Patch("/", api.checkProfileOwnership(api.updateUserHandler)) r.Patch("/", api.checkProfileOwnership(api.updateUserHandler))
r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler))
r.Put(("/follow"), api.followUserHandler)
r.Put(("/unfollow"), api.unfollowUserHandler)
})
r.Group(func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/feed", api.getUserFeedHandler)
}) })
}) })
r.Route("/authentication", func(r chi.Router) { r.Route("/authentication", func(r chi.Router) {
r.Post("/user", api.registerUserHandler) r.Post("/register", api.registerUserHandler)
r.Post("/token", api.createTokenHandler) r.Post("/token", api.createTokenHandler)
r.Post("/forgot-password", api.forgotPasswordHandler)
r.Post("/refresh", api.refreshTokenHandler)
})
r.Route("/admin", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUsersHandler))
r.Route("/{id}", func(r chi.Router) {
//r.Get("/", api.protectAdminRoutes("moderator", api.getUserByIdHandler))
})
})
}) })
}) })
@@ -204,6 +214,12 @@ func (api *api) run(mux http.Handler) error {
return err return err
} }
if userStore, ok := api.store.Users.(*store.UserStore); ok {
if err := userStore.Close(); err != nil {
api.logger.Errorw("error closing userstore", err)
}
}
api.logger.Infow("server has been shutdown") api.logger.Infow("server has been shutdown")
return nil return nil
+1 -1
View File
@@ -28,7 +28,7 @@ func TestRateLimiterMiddleware(t *testing.T) {
mockIP := "192.168.1.1" mockIP := "192.168.1.1"
marginOfError := 2 marginOfError := 2
for i := 0; i < cfg.rateLimiter.RequestsPerTimeFrame+marginOfError; i++ { for i := range cfg.rateLimiter.RequestsPerTimeFrame + marginOfError {
req, err := http.NewRequest("GET", ts.URL+"/v1/health", nil) req, err := http.NewRequest("GET", ts.URL+"/v1/health", nil)
if err != nil { if err != nil {
t.Fatalf("failed to create request: %v", err) t.Fatalf("failed to create request: %v", err)
+183 -8
View File
@@ -5,7 +5,10 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"github.com/FernandoVideira/LK_API_Temp/internal/mailer" "github.com/FernandoVideira/LK_API_Temp/internal/mailer"
@@ -27,6 +30,15 @@ type UserWithToken struct {
Token string `json:"token"` Token string `json:"token"`
} }
type ForgotPasswordPayload struct {
Email string `json:"email" validate:"required,email,max=255"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
}
// RegisterUser godoc // RegisterUser godoc
// //
// @Summary Register a new User // @Summary Register a new User
@@ -38,7 +50,7 @@ type UserWithToken struct {
// @Success 201 {object} UserWithToken "User Registered" // @Success 201 {object} UserWithToken "User Registered"
// @Failure 400 {object} error "User payload error" // @Failure 400 {object} error "User payload error"
// @Failure 500 {object} error "Internal Server Error" // @Failure 500 {object} error "Internal Server Error"
// @Router /authentication/user [post] // @Router /authentication/register [post]
func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) { func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
var payload RegisterUserPayload var payload RegisterUserPayload
if err := readJSON(w, r, &payload); err != nil { if err := readJSON(w, r, &payload); err != nil {
@@ -75,7 +87,7 @@ func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
hashedToken := hex.EncodeToString(hash[:]) hashedToken := hex.EncodeToString(hash[:])
// Store the user // Store the user
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp) err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.inviteExp)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, store.ErrDuplicateEmail): case errors.Is(err, store.ErrDuplicateEmail):
@@ -178,6 +190,160 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
//* send the token back to the user
authResponse, err := api.createTokens(user)
if err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusCreated, authResponse); err != nil {
api.internalServerError(w, r, err)
return
}
}
// RefreshTokenHandler godoc
//
// @Summary Refresh a token
// @Description Refresh a token for the user
// @Tags authentication
// @Accept json
// @Produce json
// @Param Authorization header string true "Refresh Token"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/refresh [post]
func (api *api) refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
//* parse the refresh token from the request
refreshHeader := r.Header.Get("Authorization")
if refreshHeader == "" {
api.unauthorizedError(w, r, errors.New("refresh token is required"))
return
}
parts := strings.Split(refreshHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
api.unauthorizedError(w, r, fmt.Errorf("invalid Authorization header"))
return
}
refreshToken := parts[1]
token, err := api.authenticator.ValidateRefreshToken(refreshToken)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
claims := token.Claims.(jwt.MapClaims)
//* get the user id from the token
userID, err := strconv.ParseInt(fmt.Sprintf("%.f", claims["sub"]), 10, 64)
if err != nil {
api.unauthorizedError(w, r, fmt.Errorf("invalid token"))
return
}
//* get the user from the database
user, err := api.store.Users.GetByID(r.Context(), userID)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
authResponse, err := api.createTokens(user)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusCreated, authResponse); err != nil {
api.internalServerError(w, r, err)
return
}
}
// ResetPasswordHandler godoc
//
// @Summary Forgot Password
// @Description Forgot Password
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body ForgotPasswordPayload true "User Credentials"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/forgot-password [post]
func (api *api) forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
var payload ForgotPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user, err := api.store.Users.GetByEmail(r.Context(), payload.Email)
if err != nil {
switch err {
case store.ErrNotFound:
api.badRequestError(w, r, err)
default:
api.internalServerError(w, r, err)
}
}
plainToken := uuid.New().String()
hash := sha256.Sum256([]byte(plainToken))
hashedToken := hex.EncodeToString(hash[:])
//*Store The Request
err = api.store.Users.PasswordResetRequest(r.Context(), user.ID, hashedToken, api.config.mail.passwordRefreshExp)
if err != nil {
api.internalServerError(w, r, err)
return
}
updateURL := fmt.Sprintf("%s/reset-password/%s", api.config.frontEndURL, plainToken)
isProdEnv := api.config.env == "production"
vars := struct {
Username string
ServiceName string
UpdateURL string
Email string
CurrentYear string
}{
Username: user.Username,
ServiceName: "LK_API_Temp",
UpdateURL: updateURL,
Email: user.Email,
CurrentYear: time.Now().Format("2006"),
}
status, err := api.mailer.Send(mailer.ForgotPasswordTemplate, user.Username, user.Email, vars, !isProdEnv)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.logger.Infow("email sent", "status code", status)
if err := api.jsonResponse(w, http.StatusCreated, plainToken); err != nil {
api.internalServerError(w, r, err)
return
}
}
func (api *api) createTokens(user *store.User) (AuthResponse, error) {
claims := &jwt.MapClaims{ claims := &jwt.MapClaims{
"sub": user.ID, "sub": user.ID,
"exp": time.Now().Add(api.config.auth.token.exp).Unix(), "exp": time.Now().Add(api.config.auth.token.exp).Unix(),
@@ -190,14 +356,23 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
//* generate a new token -> add the claims //* generate a new token -> add the claims
token, err := api.authenticator.GenerateToken(claims) token, err := api.authenticator.GenerateToken(claims)
if err != nil { if err != nil {
api.internalServerError(w, r, err) return AuthResponse{}, err
return
} }
//* send the token back to the user refreshClaims := &jwt.MapClaims{
"sub": user.ID,
"exp": time.Now().Add(api.config.auth.token.refreshExp).Unix(),
}
if err := api.jsonResponse(w, http.StatusCreated, token); err != nil { log.Println(time.Now().Add(api.config.auth.token.refreshExp).Unix())
api.internalServerError(w, r, err)
return refreshToken, err := api.authenticator.GenerateRefreshToken(refreshClaims)
if err != nil {
return AuthResponse{}, err
} }
return AuthResponse{
Token: token,
RefreshToken: refreshToken,
}, nil
} }
-2
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"log"
"net/http" "net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store" "github.com/FernandoVideira/LK_API_Temp/internal/store"
@@ -48,7 +47,6 @@ func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
user := getUserFromContext(r) user := getUserFromContext(r)
log.Println(user)
feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq) feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq)
if err != nil { if err != nil {
api.internalServerError(w, r, err) api.internalServerError(w, r, err)
+7 -2
View File
@@ -56,7 +56,8 @@ func main() {
}, },
env: env.GetString("ENV", "development"), env: env.GetString("ENV", "development"),
mail: mailConfig{ mail: mailConfig{
exp: time.Hour * 24 * 3, inviteExp: time.Hour * 24 * 3,
passwordRefreshExp: time.Hour * 24 * 7,
fromEmail: env.GetString("FROM_EMAIL", ""), fromEmail: env.GetString("FROM_EMAIL", ""),
sendGrid: sendGridConfig{ sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""), apiKey: env.GetString("SENDGRID_API_KEY", ""),
@@ -70,6 +71,7 @@ func main() {
token: tokenConfig{ token: tokenConfig{
secret: env.GetString("AUTH_TOKEN_SECRET", "secret"), secret: env.GetString("AUTH_TOKEN_SECRET", "secret"),
exp: env.GetDuration("AUTH_TOKEN_EXP", "24h"), exp: env.GetDuration("AUTH_TOKEN_EXP", "24h"),
refreshExp: time.Hour * 24 * 7,
iss: env.GetString("AUTH_TOKEN_ISS", "lkapi"), iss: env.GetString("AUTH_TOKEN_ISS", "lkapi"),
aud: env.GetString("AUTH_TOKEN_AUD", "lkapi"), aud: env.GetString("AUTH_TOKEN_AUD", "lkapi"),
}, },
@@ -111,7 +113,10 @@ func main() {
cfg.rateLimiter.TimeFrame, cfg.rateLimiter.TimeFrame,
) )
store := store.NewStorage(db) store, err := store.NewStorage(db)
if err != nil {
logger.Fatal(err)
}
cacheStorage := cache.NewRedisStorage(redisDb) cacheStorage := cache.NewRedisStorage(redisDb)
mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail) mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
+34 -14
View File
@@ -104,6 +104,7 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
api.badRequestError(w, r, err) api.badRequestError(w, r, err)
return return
} }
ctx := r.Context() ctx := r.Context()
post, err := api.store.Posts.GetByID(ctx, id) post, err := api.store.Posts.GetByID(ctx, id)
@@ -111,17 +112,31 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
switch { switch {
case errors.Is(err, store.ErrNotFound): case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err) api.notFoundError(w, r, err)
return
default: default:
api.internalServerError(w, r, err) api.internalServerError(w, r, err)
}
return return
} }
}
ctx = context.WithValue(ctx, postCtx, post) ctx = context.WithValue(ctx, postCtx, post)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
func (api *api) rateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.config.rateLimiter.Enabled {
if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow {
api.rateLimitExceededError(w, r, retryAfter.String())
return
}
}
next.ServeHTTP(w, r)
})
}
func (api *api) checkPostOwnership(requiredRole string, next http.HandlerFunc) http.HandlerFunc { func (api *api) checkPostOwnership(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r) user := getUserFromContext(r)
@@ -161,6 +176,24 @@ func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc {
}) })
} }
func (api *api) protectAdminRoutes(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole)
if err != nil {
api.internalServerError(w, r, err)
return
}
if !allowed {
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
return
}
next.ServeHTTP(w, r)
})
}
func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) { func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) {
role, err := api.store.Roles.GetByName(ctx, roleName) role, err := api.store.Roles.GetByName(ctx, roleName)
if err != nil { if err != nil {
@@ -193,16 +226,3 @@ func (api *api) getUser(ctx context.Context, id int64) (*store.User, error) {
return user, nil return user, nil
} }
func (api *api) RateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.config.rateLimiter.Enabled {
if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow {
api.rateLimitExceededError(w, r, retryAfter.String())
return
}
}
next.ServeHTTP(w, r)
})
}
+84 -2
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"log"
"net/http" "net/http"
"strconv" "strconv"
@@ -12,8 +11,39 @@ import (
) )
type userKey string type userKey string
type roleKey string
const userContextKey userKey = "user" const userContextKey userKey = "user"
const roleContextKey roleKey = "role"
// GetUser godoc
//
// @Summary Fetches all users
// @Description Fetches all users
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /admin/users [get]
func (api *api) getUsersHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
api.unauthorizedError(w, r, errors.New("user not found"))
return
}
users, err := api.store.Users.GetAll(r.Context(), user.ID, user.Role.Level)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.jsonResponse(w, http.StatusOK, users)
}
// GetUser godoc // GetUser godoc
// //
@@ -54,6 +84,57 @@ func (api *api) getUserHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
type resetPasswordPayload struct {
NewPassword string `json:"new_password" validate:"required,password"`
NewPasswordConfirmation string `json:"new_password_confirmation" validate:"required,eqfield=NewPassword"`
}
// ResetPassword godoc
//
// @Summary Reset user password
// @Description Reset user password with verification
// @Tags users
// @Accept json
// @Param payload body resetPasswordPayload true "Reset Password"
// @Param token path string true "Reset Password Token"
// @Success 204 {string} string "Password reset successfully"
// @Failure 400 {object} error "Invalid request"
// @Failure 401 {object} error "Unauthorized"
// @Failure 500 {object} error "Internal server error"
// @Security ApiKeyAuth
// @Router /users/reset-password/{token} [put]
func (api *api) resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
var payload resetPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
err := api.store.Users.ResetPassword(r.Context(), token, payload.NewPassword)
if err != nil {
switch err {
case store.ErrNotFound:
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil {
api.internalServerError(w, r, err)
}
}
type updateUserPayload struct { type updateUserPayload struct {
FirstName *string `json:"first_name" validate:"omitempty,max=100"` FirstName *string `json:"first_name" validate:"omitempty,max=100"`
LastName *string `json:"last_name" validate:"omitempty,max=100"` LastName *string `json:"last_name" validate:"omitempty,max=100"`
@@ -78,7 +159,6 @@ type updateUserPayload struct {
// @Router /users/{id} [patch] // @Router /users/{id} [patch]
func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) { func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r) user := getUserFromContext(r)
log.Println(user)
var payload updateUserPayload var payload updateUserPayload
if err := readJSON(w, r, &payload); err != nil { if err := readJSON(w, r, &payload); err != nil {
@@ -103,7 +183,9 @@ func (api *api) updateUser(ctx context.Context, user *store.User) error {
return err return err
} }
if api.config.redisCfg.enabled {
api.cacheStore.Users.Delete(ctx, user.ID) api.cacheStore.Users.Delete(ctx, user.ID)
}
return nil return nil
} }
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_requests;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS password_reset_requests (
token bytea PRIMARY KEY,
user_id bigint NOT NULL,
expiry TIMESTAMP(0)
WITH
TIME ZONE NOT NULL
);
+5 -1
View File
@@ -16,6 +16,10 @@ func main() {
} }
defer conn.Close() defer conn.Close()
store := store.NewStorage(conn) store, err := store.NewStorage(conn)
if err != nil {
log.Fatalf("could not create storage: %v", err)
}
db.Seed(store, conn) db.Seed(store, conn)
} }
+254 -42
View File
@@ -24,6 +24,178 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/admin/users": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Fetches all users",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Fetches all users",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/store.User"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/refresh": {
"post": {
"description": "Refresh a token for the user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Refresh a token",
"parameters": [
{
"type": "string",
"description": "Refresh Token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/register": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": { "/authentication/token": {
"post": { "post": {
"description": "Create a new token for the user", "description": "Create a new token for the user",
@@ -70,48 +242,6 @@ const docTemplate = `{
} }
} }
}, },
"/authentication/user": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/health": { "/health": {
"get": { "get": {
"description": "Health Check", "description": "Health Check",
@@ -509,6 +639,61 @@ const docTemplate = `{
} }
} }
}, },
"/users/reset-password/{token}": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Reset user password with verification",
"consumes": [
"application/json"
],
"tags": [
"users"
],
"summary": "Reset user password",
"parameters": [
{
"description": "Reset Password",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.resetPasswordPayload"
}
},
{
"type": "string",
"description": "Reset Password Token",
"name": "token",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Password reset successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {}
}
}
}
},
"/users/{id}": { "/users/{id}": {
"get": { "get": {
"security": [ "security": [
@@ -795,6 +980,18 @@ const docTemplate = `{
} }
} }
}, },
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": { "main.RegisterUserPayload": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -877,6 +1074,21 @@ const docTemplate = `{
} }
} }
}, },
"main.resetPasswordPayload": {
"type": "object",
"required": [
"new_password",
"new_password_confirmation"
],
"properties": {
"new_password": {
"type": "string"
},
"new_password_confirmation": {
"type": "string"
}
}
},
"main.updateUserPasswordPayload": { "main.updateUserPasswordPayload": {
"type": "object", "type": "object",
"required": [ "required": [
+254 -42
View File
@@ -16,6 +16,178 @@
}, },
"basePath": "/v1", "basePath": "/v1",
"paths": { "paths": {
"/admin/users": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Fetches all users",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Fetches all users",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/store.User"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/refresh": {
"post": {
"description": "Refresh a token for the user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Refresh a token",
"parameters": [
{
"type": "string",
"description": "Refresh Token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/register": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": { "/authentication/token": {
"post": { "post": {
"description": "Create a new token for the user", "description": "Create a new token for the user",
@@ -62,48 +234,6 @@
} }
} }
}, },
"/authentication/user": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/health": { "/health": {
"get": { "get": {
"description": "Health Check", "description": "Health Check",
@@ -501,6 +631,61 @@
} }
} }
}, },
"/users/reset-password/{token}": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Reset user password with verification",
"consumes": [
"application/json"
],
"tags": [
"users"
],
"summary": "Reset user password",
"parameters": [
{
"description": "Reset Password",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.resetPasswordPayload"
}
},
{
"type": "string",
"description": "Reset Password Token",
"name": "token",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Password reset successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {}
}
}
}
},
"/users/{id}": { "/users/{id}": {
"get": { "get": {
"security": [ "security": [
@@ -787,6 +972,18 @@
} }
} }
}, },
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": { "main.RegisterUserPayload": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -869,6 +1066,21 @@
} }
} }
}, },
"main.resetPasswordPayload": {
"type": "object",
"required": [
"new_password",
"new_password_confirmation"
],
"properties": {
"new_password": {
"type": "string"
},
"new_password_confirmation": {
"type": "string"
}
}
},
"main.updateUserPasswordPayload": { "main.updateUserPasswordPayload": {
"type": "object", "type": "object",
"required": [ "required": [
+169 -28
View File
@@ -29,6 +29,14 @@ definitions:
- email - email
- password - password
type: object type: object
main.ForgotPasswordPayload:
properties:
email:
maxLength: 255
type: string
required:
- email
type: object
main.RegisterUserPayload: main.RegisterUserPayload:
properties: properties:
email: email:
@@ -86,6 +94,16 @@ definitions:
username: username:
type: string type: string
type: object type: object
main.resetPasswordPayload:
properties:
new_password:
type: string
new_password_confirmation:
type: string
required:
- new_password
- new_password_confirmation
type: object
main.updateUserPasswordPayload: main.updateUserPasswordPayload:
properties: properties:
current_password: current_password:
@@ -230,6 +248,121 @@ info:
termsOfService: http://swagger.io/terms/ termsOfService: http://swagger.io/terms/
title: LK API Template title: LK API Template
paths: paths:
/admin/users:
get:
consumes:
- application/json
description: Fetches all users
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.User'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Fetches all users
tags:
- admin
/authentication/forgot-password:
post:
consumes:
- application/json
description: Forgot Password
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.ForgotPasswordPayload'
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Forgot Password
tags:
- authentication
/authentication/refresh:
post:
consumes:
- application/json
description: Refresh a token for the user
parameters:
- description: Refresh Token
in: header
name: Authorization
required: true
type: string
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Refresh a token
tags:
- authentication
/authentication/register:
post:
consumes:
- application/json
description: Register a new User
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.RegisterUserPayload'
produces:
- application/json
responses:
"201":
description: User Registered
schema:
$ref: '#/definitions/main.UserWithToken'
"400":
description: User payload error
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Register a new User
tags:
- authentication
/authentication/token: /authentication/token:
post: post:
consumes: consumes:
@@ -261,34 +394,6 @@ paths:
summary: Create a new token summary: Create a new token
tags: tags:
- authentication - authentication
/authentication/user:
post:
consumes:
- application/json
description: Register a new User
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.RegisterUserPayload'
produces:
- application/json
responses:
"201":
description: User Registered
schema:
$ref: '#/definitions/main.UserWithToken'
"400":
description: User payload error
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Register a new User
tags:
- authentication
/health: /health:
get: get:
description: Health Check description: Health Check
@@ -717,6 +822,42 @@ paths:
summary: Fetches the user feed summary: Fetches the user feed
tags: tags:
- feed - feed
/users/reset-password/{token}:
put:
consumes:
- application/json
description: Reset user password with verification
parameters:
- description: Reset Password
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.resetPasswordPayload'
- description: Reset Password Token
in: path
name: token
required: true
type: string
responses:
"204":
description: Password reset successfully
schema:
type: string
"400":
description: Invalid request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal server error
schema: {}
security:
- ApiKeyAuth: []
summary: Reset user password
tags:
- users
securityDefinitions: securityDefinitions:
ApiKeyAuth: ApiKeyAuth:
in: header in: header
+3
View File
@@ -3,6 +3,7 @@ module github.com/FernandoVideira/LK_API_Temp
go 1.23.4 go 1.23.4
require ( require (
github.com/Masterminds/squirrel v1.5.4
github.com/go-chi/chi/v5 v5.2.0 github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
@@ -31,6 +32,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
+7
View File
@@ -1,5 +1,7 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,6 +44,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -64,6 +70,7 @@ github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBU
github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+2
View File
@@ -4,5 +4,7 @@ import "github.com/golang-jwt/jwt/v5"
type Authenticator interface { type Authenticator interface {
GenerateToken(jwt.Claims) (string, error) GenerateToken(jwt.Claims) (string, error)
GenerateRefreshToken(jwt.Claims) (string, error)
ValidateToken(string) (*jwt.Token, error) ValidateToken(string) (*jwt.Token, error)
ValidateRefreshToken(string) (*jwt.Token, error)
} }
+24
View File
@@ -31,6 +31,17 @@ func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
return tokenString, nil return tokenString, nil
} }
func (j *JWTAuthenticator) GenerateRefreshToken(claims jwt.Claims) (string, error) {
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
refreshTokenString, err := refreshToken.SignedString([]byte(j.secret))
if err != nil {
return "", err
}
return refreshTokenString, nil
}
func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) { func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@@ -45,3 +56,16 @@ func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error)
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
) )
} }
func (j *JWTAuthenticator) ValidateRefreshToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(j.secret), nil
},
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
)
}
+12
View File
@@ -24,8 +24,20 @@ func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
return token.SignedString([]byte(testToken)) return token.SignedString([]byte(testToken))
} }
func (a *TestAuthenticator) GenerateRefreshToken(claims jwt.Claims) (string, error) {
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims)
return refreshToken.SignedString([]byte(testToken))
}
func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) { func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) { return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil return []byte(testToken), nil
}) })
} }
func (a *TestAuthenticator) ValidateRefreshToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil
})
}
+1
View File
@@ -6,6 +6,7 @@ const (
FromName = "LK API" FromName = "LK API"
maxRetries = 3 maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl" UserWelcomeTemplate = "user_activation.tmpl"
ForgotPasswordTemplate = "forgot_password.tmpl"
) )
//go:embed "templates" //go:embed "templates"
@@ -0,0 +1,27 @@
{{define "subject"}} Reset Your Password - {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hello {{.Username}},</p>
<p>We received a request to reset your password. If you didn't make this request, you can ignore this email.</p>
<p>To reset your password, click on the link below:</p>
<p><a href="{{.UpdateURL}}">{{.UpdateURL}}</a></p>
<p>If you want to reset your password manually, copy and paste the link above into your browser.</p>
<p>If you have any questions or need assistance, please contact our support team.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
-3
View File
@@ -1,7 +1,6 @@
package ratelimiter package ratelimiter
import ( import (
"log"
"sync" "sync"
"time" "time"
) )
@@ -26,8 +25,6 @@ func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
count, exists := rl.clients[ip] count, exists := rl.clients[ip]
rl.RUnlock() rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit { if !exists || count < rl.limit {
rl.Lock() rl.Lock()
if !exists { if !exists {
+15
View File
@@ -28,6 +28,11 @@ func (m *MockUserStore) Activate(ctx context.Context, token string) error {
return args.Error(0) return args.Error(0)
} }
func (m *MockUserStore) GetAll(ctx context.Context, userID int64, roleLevel int64) ([]*User, error) {
args := m.Called(userID, roleLevel)
return nil, args.Error(1)
}
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) { func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
args := m.Called(id) args := m.Called(id)
return nil, args.Error(1) return nil, args.Error(1)
@@ -48,6 +53,16 @@ func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token s
return args.Error(0) return args.Error(0)
} }
func (m *MockUserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
args := m.Called(ctx, userID, token, expiration)
return args.Error(0)
}
func (m *MockUserStore) ResetPassword(ctx context.Context, token string, password string) error {
args := m.Called(ctx, token, password)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error { func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id) args := m.Called(id)
return args.Error(0) return args.Error(0)
+16 -4
View File
@@ -14,21 +14,33 @@ type Role struct {
type RoleStore struct { type RoleStore struct {
db *sql.DB db *sql.DB
getByNameStmt *sql.Stmt
} }
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) { func NewRoleStore(db *sql.DB) (*RoleStore, error) {
query := ` getByNameStmt, err := db.PrepareContext(context.Background(), `
SELECT id, name, level, description SELECT id, name, level, description
FROM roles FROM roles
WHERE name = $1 WHERE name = $1
` `)
if err != nil {
return nil, err
}
return &RoleStore{
db: db,
getByNameStmt: getByNameStmt,
}, nil
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
role := &Role{} role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan( err := s.getByNameStmt.QueryRowContext(ctx, name).Scan(
&role.ID, &role.ID,
&role.Name, &role.Name,
&role.Level, &role.Level,
+27 -4
View File
@@ -27,10 +27,13 @@ type Storage struct {
Users interface { Users interface {
Create(context.Context, *sql.Tx, *User) error Create(context.Context, *sql.Tx, *User) error
Activate(context.Context, string) error Activate(context.Context, string) error
GetAll(context.Context, int64, int64) ([]*User, error)
GetByID(context.Context, int64) (*User, error) GetByID(context.Context, int64) (*User, error)
GetByEmail(context.Context, string) (*User, error) GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error CreateAndInvite(context.Context, *User, string, time.Duration) error
PasswordResetRequest(context.Context, int64, string, time.Duration) error
ResetPassword(context.Context, string, string) error
Delete(context.Context, int64) error Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error DeleteUser(context.Context, int64) error
} }
@@ -47,14 +50,20 @@ type Storage struct {
} }
} }
func NewStorage(db *sql.DB) Storage { func NewStorage(db *sql.DB) (Storage, error) {
userStore, roleStore, err := initStores(db)
if err != nil {
return Storage{}, err
}
return Storage{ return Storage{
Posts: &PostStore{db}, Posts: &PostStore{db},
Users: &UserStore{db}, Users: userStore,
Comments: &CommentStore{db}, Comments: &CommentStore{db},
Followers: &FollowerStore{db}, Followers: &FollowerStore{db},
Roles: &RoleStore{db}, Roles: roleStore,
} }, nil
} }
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error { func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
@@ -70,3 +79,17 @@ func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
return tx.Commit() return tx.Commit()
} }
func initStores(db *sql.DB) (*UserStore, *RoleStore, error) {
userStore, err := NewUserStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
roleStore, err := NewRoleStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
return userStore, roleStore, nil
}
+341 -71
View File
@@ -8,6 +8,14 @@ import (
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
sq "github.com/Masterminds/squirrel"
)
const (
adminLevel = 3
modLevel = 2
userLevel = 1
) )
type User struct { type User struct {
@@ -29,6 +37,8 @@ type password struct {
hash []byte hash []byte
} }
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
func (p *password) Set(password string) error { func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -42,30 +52,163 @@ func (p *password) Set(password string) error {
type UserStore struct { type UserStore struct {
db *sql.DB db *sql.DB
getByIDStmt *sql.Stmt
getByEmailStmt *sql.Stmt
getByInvitationStmt *sql.Stmt
getUserFromPasswordTokenStmt *sql.Stmt
updateStmt *sql.Stmt
deleteStmt *sql.Stmt
inviteCreateStmt *sql.Stmt
invitationDeleteStmt *sql.Stmt
deletePasswordResetRequestStmt *sql.Stmt
passwordResetRequestCreateStmt *sql.Stmt
}
func NewUserStore(db *sql.DB) (*UserStore, error) {
store := &UserStore{
db: db,
}
var err error
store.getByIDStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.id = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByEmailStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.email = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByInvitationStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`)
if err != nil {
return nil, err
}
store.getUserFromPasswordTokenStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at
FROM users u
JOIN password_reset_requests pr ON u.id = pr.user_id
WHERE u.id = $1 AND pr.expiry > $2
`)
if err != nil {
return nil, err
}
store.updateStmt, err = db.Prepare(`
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5, updated_at = $6
WHERE id = $7
`)
if err != nil {
return nil, err
}
store.deleteStmt, err = db.Prepare(`
DELETE FROM users WHERE id = $1
`)
if err != nil {
return nil, err
}
store.invitationDeleteStmt, err = db.Prepare(`
DELETE FROM invitations WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.deletePasswordResetRequestStmt, err = db.Prepare(`
DELETE FROM password_reset_requests WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.inviteCreateStmt, err = db.Prepare(`
INSERT INTO invitations (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
store.passwordResetRequestCreateStmt, err = db.Prepare(`
INSERT INTO password_reset_requests (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
return store, nil
}
func (s *UserStore) Close() error {
var errs []error
if err := s.getByIDStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByEmailStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByInvitationStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.updateStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.deleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.invitationDeleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errs[0]
}
return nil
} }
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error { func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name role := user.Role.Name
if role == "" { if role == "" {
role = "user" role = "user"
} }
err := tx.QueryRowContext( query, args, err := psql.Insert("users").
Columns("first_name", "last_name", "username", "email", "password", "role_id").
Values(user.FirstName, user.LastName, user.Username, user.Email, user.Password.hash, sq.Expr("(SELECT id FROM roles WHERE name = ?)", role)).
Suffix("RETURNING id, created_at, updated_at").ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err = tx.QueryRowContext(
ctx, ctx,
query, query,
user.FirstName, args...,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
role,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil { if err != nil {
switch { switch {
@@ -81,28 +224,76 @@ func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
return nil return nil
} }
func (s *UserStore) GetAll(ctx context.Context, userID int64, roleLevel int64) ([]*User, error) {
var whereConditions []sq.Sqlizer
whereConditions = append(whereConditions, sq.Eq{"u.is_active": true})
if roleLevel == modLevel {
whereConditions = append(whereConditions, sq.Or{
sq.Lt{"roles.level": roleLevel},
sq.Eq{"u.id": userID},
})
}
query, args, err := psql.Select("u.id", "u.first_name", "u.last_name", "u.username", "u.email", "u.is_active", "u.created_at", "u.updated_at", "roles.*").
From("users u").Join("roles ON u.role_id = roles.id").Where(sq.And(
whereConditions,
)).OrderBy("u.created_at DESC").
ToSql()
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]*User, 0)
for rows.Next() {
user := &User{}
err := rows.Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.IsActive,
&user.CreatedAt,
&user.UpdatedAt,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) { func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
user := &User{} user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan( err := s.getByIDStmt.QueryRowContext(ctx, id).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
&user.Username, &user.Username,
&user.Email, &user.Email,
&user.Password.hash, &user.Password.hash,
&user.IsActive,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
&user.Role.ID, &user.RoleID,
&user.Role.Name, &user.Role.Name,
&user.Role.Level, &user.Role.Level,
&user.Role.Description, &user.Role.Description,
@@ -115,34 +306,26 @@ func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
return nil, err return nil, err
} }
} }
return user, nil return user, nil
} }
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) { func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
user := &User{} user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan( err := s.getByEmailStmt.QueryRowContext(ctx, email).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
&user.Username, &user.Username,
&user.Email, &user.Email,
&user.Password.hash, &user.Password.hash,
&user.IsActive,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
&user.Role.ID, &user.RoleID,
&user.Role.Name, &user.Role.Name,
&user.Role.Level, &user.Role.Level,
&user.Role.Description, &user.Role.Description,
@@ -160,17 +343,11 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
} }
func (s *UserStore) Update(ctx context.Context, user *User) error { func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := s.db.ExecContext( _, err := s.updateStmt.ExecContext(
ctx, ctx,
query,
user.FirstName, user.FirstName,
user.LastName, user.LastName,
user.Username, user.Username,
@@ -199,10 +376,40 @@ func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token strin
}) })
} }
func (s *UserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.createPasswordResetRequest(ctx, tx, userID, token, expiration); err != nil {
return err
}
return nil
})
}
func (s *UserStore) ResetPassword(ctx context.Context, token string, newPassword string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
user, err := s.getUserFromPaswordToken(ctx, tx, token)
if err != nil {
return err
}
user.Password.Set(newPassword)
if err := s.update(ctx, tx, user); err != nil {
return err
}
if err := s.deletePasswordResetRequest(ctx, tx, user.ID); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error { func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error { return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to // Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token) user, err := s.getUserFromInvitation(ctx, tx, token)
if err != nil { if err != nil {
return err return err
} }
@@ -238,12 +445,18 @@ func (s *UserStore) Delete(ctx context.Context, id int64) error {
// * Soft delete user // * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error { func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1` query, _, err := psql.Update("users").
Set("is_active", false).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := s.db.ExecContext(ctx, query, id) _, err = s.db.ExecContext(ctx, query, id)
if err != nil { if err != nil {
return err return err
} }
@@ -255,22 +468,18 @@ func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password)) return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
} }
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) { func (s *UserStore) getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
hashed := sha256.Sum256([]byte(token)) hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:]) hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
stmt := tx.Stmt(s.getByInvitationStmt)
defer stmt.Close()
user := &User{} user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan( err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID, &user.ID,
&user.FirstName, &user.FirstName,
&user.LastName, &user.LastName,
@@ -293,14 +502,13 @@ func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User
} }
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error { func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp)) stmt := tx.Stmt(s.inviteCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(exp))
if err != nil { if err != nil {
return err return err
} }
@@ -308,18 +516,63 @@ func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID
return nil return nil
} }
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error { func (s *UserStore) createPasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64, token string, expiry time.Duration) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext( stmt := tx.Stmt(s.passwordResetRequestCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(expiry))
if err != nil {
return err
}
return nil
}
func (s *UserStore) getUserFromPaswordToken(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.getUserFromPasswordTokenStmt)
defer stmt.Close()
user := &User{}
err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.IsActive,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.updateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(
ctx, ctx,
query,
user.FirstName, user.FirstName,
user.LastName, user.LastName,
user.Username, user.Username,
@@ -335,12 +588,28 @@ func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
} }
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error { func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, userID) stmt := tx.Stmt(s.invitationDeleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil {
return err
}
return nil
}
func (s *UserStore) deletePasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.deletePasswordResetRequestStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil { if err != nil {
return err return err
} }
@@ -349,12 +618,13 @@ func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID
} }
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error { func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout) ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel() defer cancel()
_, err := tx.ExecContext(ctx, query, id) stmt := tx.Stmt(s.deleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, id)
if err != nil { if err != nil {
return err return err
} }
-24
View File
@@ -1,24 +0,0 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
-75
View File
@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
-6
View File
@@ -1,6 +0,0 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
</template>
BIN
View File
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
-15
View File
@@ -1,15 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: [
'@nuxt/content',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/image',
'@nuxt/scripts',
'@nuxt/test-utils'
]
})
-26
View File
@@ -1,26 +0,0 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/content": "3.4.0",
"@nuxt/eslint": "1.3.0",
"@nuxt/fonts": "0.11.0",
"@nuxt/icon": "1.11.0",
"@nuxt/image": "1.10.0",
"@nuxt/scripts": "0.11.5",
"@nuxt/test-utils": "3.17.2",
"@unhead/vue": "^2.0.0-rc.8",
"eslint": "^9.0.0",
"nuxt": "^3.16.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-1
View File
@@ -1 +0,0 @@
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
-4
View File
@@ -1,4 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}