From f75c4ab05e166a5a1b549b12e69f99027598ed82 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Wed, 20 Aug 2025 23:46:10 +0400 Subject: [PATCH 01/18] Refactors API and enhances authentication Restructures core API directories and packages for improved organization and clarity. Upgrades the JWT authentication system to use access and refresh token pairs. Introduces a flexible JWT middleware with support for user loading, caching, and distinct authentication levels. Modifies user login to issue and set secure HTTP-only token cookies. Updates JWT claims to include user ID for more robust session management. --- api/cmd/web/main.go | 4 +- api/cmd/worker/main.go | 4 +- api/{internal => }/common/env.go | 0 api/{internal => }/common/error.go | 0 api/{internal => }/common/id.go | 0 api/{internal => }/common/password.go | 0 api/{internal => }/common/river/client.go | 2 +- api/{internal => }/common/timestamp.go | 0 api/common/token.go | 129 ++++++++++++++ api/internal/common/token.go | 34 ---- .../client/client.go | 2 +- .../collections/chapters.go | 2 +- .../collections/novels.go | 2 +- .../collections/users.go | 2 +- api/internal/domain/token.go | 42 +++++ api/internal/domain/user.go | 1 + .../interfaces/rest/middleware/token.go | 106 ------------ .../rest => server}/handlers/chapters.go | 4 +- .../rest => server}/handlers/novels.go | 8 +- .../rest => server}/handlers/users.go | 25 ++- api/internal/server/helper/token.go | 17 ++ .../rest => server}/middleware/is_admin.go | 0 .../middleware/rate_limiter.go | 0 api/internal/server/middleware/token.go | 157 ++++++++++++++++++ .../{interfaces/rest => server}/routes.go | 32 ++-- .../{interfaces/rest => server}/server.go | 2 +- .../collections/chapters.go | 6 +- .../collections/novels.go | 6 +- .../collections/user.go | 38 +++-- .../{usecases => services}/worker/args.go | 2 +- 30 files changed, 432 insertions(+), 195 deletions(-) rename api/{internal => }/common/env.go (100%) rename api/{internal => }/common/error.go (100%) rename api/{internal => }/common/id.go (100%) rename api/{internal => }/common/password.go (100%) rename api/{internal => }/common/river/client.go (96%) rename api/{internal => }/common/timestamp.go (100%) create mode 100644 api/common/token.go delete mode 100644 api/internal/common/token.go rename api/internal/{infrastructure => database}/client/client.go (95%) rename api/internal/{infrastructure => database}/collections/chapters.go (99%) rename api/internal/{infrastructure => database}/collections/novels.go (98%) rename api/internal/{infrastructure => database}/collections/users.go (98%) create mode 100644 api/internal/domain/token.go delete mode 100644 api/internal/interfaces/rest/middleware/token.go rename api/internal/{interfaces/rest => server}/handlers/chapters.go (97%) rename api/internal/{interfaces/rest => server}/handlers/novels.go (96%) rename api/internal/{interfaces/rest => server}/handlers/users.go (82%) create mode 100644 api/internal/server/helper/token.go rename api/internal/{interfaces/rest => server}/middleware/is_admin.go (100%) rename api/internal/{interfaces/rest => server}/middleware/rate_limiter.go (100%) create mode 100644 api/internal/server/middleware/token.go rename api/internal/{interfaces/rest => server}/routes.go (55%) rename api/internal/{interfaces/rest => server}/server.go (96%) rename api/internal/{usecases => services}/collections/chapters.go (94%) rename api/internal/{usecases => services}/collections/novels.go (96%) rename api/internal/{usecases => services}/collections/user.go (72%) rename api/internal/{usecases => services}/worker/args.go (86%) diff --git a/api/cmd/web/main.go b/api/cmd/web/main.go index f64186a..5f45fb4 100644 --- a/api/cmd/web/main.go +++ b/api/cmd/web/main.go @@ -1,8 +1,8 @@ package main import ( - cmn "Codex-Backend/api/internal/common" - firestore_server "Codex-Backend/api/internal/interfaces/rest" + cmn "Codex-Backend/api/common" + firestore_server "Codex-Backend/api/internal/server" "os" _ "github.com/heroku/x/hmetrics/onload" diff --git a/api/cmd/worker/main.go b/api/cmd/worker/main.go index 47cdf16..f8d8ab6 100644 --- a/api/cmd/worker/main.go +++ b/api/cmd/worker/main.go @@ -1,8 +1,8 @@ package main import ( - cmn "Codex-Backend/api/internal/common" - queue "Codex-Backend/api/internal/common/river" + cmn "Codex-Backend/api/common" + queue "Codex-Backend/api/common/river" "context" "log" "os" diff --git a/api/internal/common/env.go b/api/common/env.go similarity index 100% rename from api/internal/common/env.go rename to api/common/env.go diff --git a/api/internal/common/error.go b/api/common/error.go similarity index 100% rename from api/internal/common/error.go rename to api/common/error.go diff --git a/api/internal/common/id.go b/api/common/id.go similarity index 100% rename from api/internal/common/id.go rename to api/common/id.go diff --git a/api/internal/common/password.go b/api/common/password.go similarity index 100% rename from api/internal/common/password.go rename to api/common/password.go diff --git a/api/internal/common/river/client.go b/api/common/river/client.go similarity index 96% rename from api/internal/common/river/client.go rename to api/common/river/client.go index 58f2b42..f3dbd20 100644 --- a/api/internal/common/river/client.go +++ b/api/common/river/client.go @@ -1,7 +1,7 @@ package queue import ( - "Codex-Backend/api/internal/usecases/worker" + "Codex-Backend/api/internal/services/worker" "context" "log" "log/slog" diff --git a/api/internal/common/timestamp.go b/api/common/timestamp.go similarity index 100% rename from api/internal/common/timestamp.go rename to api/common/timestamp.go diff --git a/api/common/token.go b/api/common/token.go new file mode 100644 index 0000000..6de576f --- /dev/null +++ b/api/common/token.go @@ -0,0 +1,129 @@ +package common + +import ( + "Codex-Backend/api/internal/domain" + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateTokenPair(ID, email string, config domain.TokenConfig) (*domain.TokenPair, error) { + if ID == "" { + return nil, &Error{Err: errors.New("user ID cannot be empty")} + } + if email == "" { + return nil, &Error{Err: errors.New("email cannot be empty")} + } + if config.SigningKey == "" { + return nil, &Error{Err: errors.New("signing key not configured")} + } + + // Generate access token + accessToken, expiresAt, err := generateAccessToken(ID, email, config) + if err != nil { + return nil, err + } + + // Generate refresh token + refreshToken, err := generateRefreshToken(ID, config) + if err != nil { + return nil, err + } + + return &domain.TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + TokenType: "Bearer", + }, nil +} + +// GenerateAccessToken creates a new access token (for refresh scenarios) +func GenerateAccessToken(ID, email string, config domain.TokenConfig) (string, time.Time, error) { + return generateAccessToken(ID, email, config) +} + +// generateAccessToken creates the actual access token +func generateAccessToken(ID, email string, config domain.TokenConfig) (string, time.Time, error) { + now := time.Now() + expirationTime := now.Add(config.AccessTTL) + + // Generate unique token ID for revocation capability + jti, err := generateJTI() + if err != nil { + return "", time.Time{}, &Error{Err: errors.New("failed to generate token ID: " + err.Error())} + } + + claims := &domain.Claims{ + ID: ID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + Subject: ID, + Audience: jwt.ClaimStrings{config.Audience}, + Issuer: config.Issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expirationTime), + NotBefore: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(config.SigningKey)) + if err != nil { + return "", time.Time{}, &Error{Err: errors.New("token signing failed: " + err.Error())} + } + + return tokenString, expirationTime, nil +} + +func generateRefreshToken(userID string, config domain.TokenConfig) (string, error) { + now := time.Now() + expirationTime := now.Add(config.RefreshTTL) + + jti, err := generateJTI() + if err != nil { + return "", &Error{Err: errors.New("failed to generate refresh token ID: " + err.Error())} + } + + // Refresh tokens have minimal claims + claims := &jwt.RegisteredClaims{ + ID: jti, + Subject: userID, + Audience: jwt.ClaimStrings{config.Audience}, + Issuer: config.Issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expirationTime), + NotBefore: jwt.NewNumericDate(now), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(config.SigningKey)) + if err != nil { + return "", &Error{Err: errors.New("refresh token signing failed: " + err.Error())} + } + + return tokenString, nil +} + +func generateJTI() (string, error) { + bytes := make([]byte, 16) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func DefaultTokenConfig() domain.TokenConfig { + return domain.TokenConfig{ + SigningKey: GetEnvVariable("JWT_SIGN_KEY"), + AccessTTL: 30 * time.Minute, + RefreshTTL: 7 * 24 * time.Hour, + Issuer: GetEnvVariable("JWT_ISSUER"), + Audience: GetEnvVariable("JWT_AUDIENCE"), + } +} diff --git a/api/internal/common/token.go b/api/internal/common/token.go deleted file mode 100644 index 65675a3..0000000 --- a/api/internal/common/token.go +++ /dev/null @@ -1,34 +0,0 @@ -package common - -import ( - "Codex-Backend/api/internal/domain" - "errors" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func GenerateToken(email string) (string, error) { - - key := GetEnvVariable("JWT_SIGN_KEY") - signKey := []byte(key) - - expirationTime := time.Now().Add(time.Hour * 24) - - calims := &domain.Claims{ - Email: email, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expirationTime), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, calims) - - tokenString, err := token.SignedString(signKey) - if err != nil { - return "", &Error{Err: errors.New("Token Service Error - Generate Token String: " + err.Error())} - } - - return tokenString, nil -} diff --git a/api/internal/infrastructure/client/client.go b/api/internal/database/client/client.go similarity index 95% rename from api/internal/infrastructure/client/client.go rename to api/internal/database/client/client.go index f15b10a..5241dd1 100644 --- a/api/internal/infrastructure/client/client.go +++ b/api/internal/database/client/client.go @@ -1,7 +1,7 @@ package firestore_client import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "context" "errors" "net/http" diff --git a/api/internal/infrastructure/collections/chapters.go b/api/internal/database/collections/chapters.go similarity index 99% rename from api/internal/infrastructure/collections/chapters.go rename to api/internal/database/collections/chapters.go index c9c2f30..11b7351 100644 --- a/api/internal/infrastructure/collections/chapters.go +++ b/api/internal/database/collections/chapters.go @@ -1,7 +1,7 @@ package firestore_collections import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" "context" "errors" diff --git a/api/internal/infrastructure/collections/novels.go b/api/internal/database/collections/novels.go similarity index 98% rename from api/internal/infrastructure/collections/novels.go rename to api/internal/database/collections/novels.go index 5f3ef50..af6a1de 100644 --- a/api/internal/infrastructure/collections/novels.go +++ b/api/internal/database/collections/novels.go @@ -1,7 +1,7 @@ package firestore_collections import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" "context" "errors" diff --git a/api/internal/infrastructure/collections/users.go b/api/internal/database/collections/users.go similarity index 98% rename from api/internal/infrastructure/collections/users.go rename to api/internal/database/collections/users.go index 7318ecf..0b33301 100644 --- a/api/internal/infrastructure/collections/users.go +++ b/api/internal/database/collections/users.go @@ -1,7 +1,7 @@ package firestore_collections import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" "context" "errors" diff --git a/api/internal/domain/token.go b/api/internal/domain/token.go new file mode 100644 index 0000000..a8d349c --- /dev/null +++ b/api/internal/domain/token.go @@ -0,0 +1,42 @@ +package domain + +import ( + "context" + "time" +) + +type TokenConfig struct { + SigningKey string + AccessTTL time.Duration + RefreshTTL time.Duration + Issuer string // e.g., "your-app-name" + Audience string // e.g., "your-app-users" +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + TokenType string `json:"token_type"` +} + +// TokenCache interface for optional token caching +type TokenCache interface { + Get(key string) (any, bool) + Set(key string, value any, duration time.Duration) + Delete(key string) +} + +// UserService interface for user operations +type UserService interface { + GetUserByID(ctx context.Context, userID string) (*User, error) + IsUserActive(ctx context.Context, userID string) (bool, error) +} + +// MiddlewareConfig holds configuration for the JWT middleware +type MiddlewareConfig struct { + UserService UserService + Cache TokenCache + CacheDuration time.Duration + SkipUserLookup bool +} diff --git a/api/internal/domain/user.go b/api/internal/domain/user.go index f9d26ea..0d66c3b 100644 --- a/api/internal/domain/user.go +++ b/api/internal/domain/user.go @@ -18,6 +18,7 @@ type Credentials struct { } type Claims struct { + ID string `json:"id"` Email string `json:"email"` jwt.RegisteredClaims } diff --git a/api/internal/interfaces/rest/middleware/token.go b/api/internal/interfaces/rest/middleware/token.go deleted file mode 100644 index 62a516f..0000000 --- a/api/internal/interfaces/rest/middleware/token.go +++ /dev/null @@ -1,106 +0,0 @@ -package firestore_middleware - -import ( - "Codex-Backend/api/internal/common" - cmn "Codex-Backend/api/internal/common" - "Codex-Backend/api/internal/domain" - firestore_client "Codex-Backend/api/internal/infrastructure/client" - firestore_collections "Codex-Backend/api/internal/infrastructure/collections" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" -) - -func ValidateToken() gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - - tokenString, err := c.Cookie("Authorization") - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": err.Error(), - }) - return - } - - token, err := jwt.ParseWithClaims(tokenString, &domain.Claims{}, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid - } - - key := cmn.GetEnvVariable("JWT_SIGN_KEY") - return []byte(key), nil - }) - - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": err.Error(), - }) - return - } - - if claims, ok := token.Claims.(*domain.Claims); ok && token.Valid { - // Check token expiration - if time.Now().After(time.Unix(claims.ExpiresAt.Unix(), 0)) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Token expired", - }) - return - } - - if claims.Email == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Mail not found in token", - }) - } - - client, err := firestore_client.FirestoreClient() - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "error": err.Error(), - }) - return - } - defer client.Close() - - cl := firestore_collections.Client{Client: client} - - // Find user - user, err := cl.GetUserByEmail(claims.Email, ctx) - if e, ok := err.(*common.Error); ok { - c.AbortWithStatusJSON(e.StatusCode(), gin.H{ - "error": "Error Verifying Cookie: " + e.Error(), - }) - return - } - - if user == nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ - "error": "User not found", - }) - return - } - - if user.Email != claims.Email { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ - "error": "User not found", - }) - return - } - - // Set user in context - c.Set("user", user) - - // Continue to handler - c.Next() - return - } else { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Invalid token", - }) - return - } - } -} diff --git a/api/internal/interfaces/rest/handlers/chapters.go b/api/internal/server/handlers/chapters.go similarity index 97% rename from api/internal/interfaces/rest/handlers/chapters.go rename to api/internal/server/handlers/chapters.go index 70f7021..d7a97e7 100644 --- a/api/internal/interfaces/rest/handlers/chapters.go +++ b/api/internal/server/handlers/chapters.go @@ -1,9 +1,9 @@ package firestore_handlers import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/usecases/collections" + firestore_services "Codex-Backend/api/internal/services/collections" "net/http" "strconv" diff --git a/api/internal/interfaces/rest/handlers/novels.go b/api/internal/server/handlers/novels.go similarity index 96% rename from api/internal/interfaces/rest/handlers/novels.go rename to api/internal/server/handlers/novels.go index 48bc890..31007b6 100644 --- a/api/internal/interfaces/rest/handlers/novels.go +++ b/api/internal/server/handlers/novels.go @@ -1,11 +1,11 @@ package firestore_handlers import ( - cmn "Codex-Backend/api/internal/common" - queue "Codex-Backend/api/internal/common/river" + cmn "Codex-Backend/api/common" + queue "Codex-Backend/api/common/river" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/usecases/collections" - "Codex-Backend/api/internal/usecases/worker" + firestore_services "Codex-Backend/api/internal/services/collections" + "Codex-Backend/api/internal/services/worker" "io" "net/http" "strings" diff --git a/api/internal/interfaces/rest/handlers/users.go b/api/internal/server/handlers/users.go similarity index 82% rename from api/internal/interfaces/rest/handlers/users.go rename to api/internal/server/handlers/users.go index c036b76..a483bb5 100644 --- a/api/internal/interfaces/rest/handlers/users.go +++ b/api/internal/server/handlers/users.go @@ -1,9 +1,9 @@ package firestore_handlers import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/usecases/collections" + firestore_services "Codex-Backend/api/internal/services/collections" "net/http" "github.com/gin-gonic/gin" @@ -67,7 +67,7 @@ func LoginUser(c *gin.Context) { return } - token, user, err := firestore_services.LoginUser(credentials, ctx) + user, err := firestore_services.LoginUser(credentials, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Error logging in the user: " + e.Error(), @@ -80,15 +80,28 @@ func LoginUser(c *gin.Context) { return } - c.SetCookie("Authorization", token, 3600*24, "/", cmn.GetEnvVariable("DOMAIN"), true, true) + config := cmn.DefaultTokenConfig() + + tokens, err := cmn.GenerateTokenPair(user.ID, user.Email, config) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "Error logging in the user: " + err.Error(), + }) + return + } + + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("access_token", tokens.AccessToken, 15*60, "/", "", true, true) + c.SetCookie("refresh_token", tokens.RefreshToken, 7*24*3600, "/auth", "", true, true) c.JSON(http.StatusOK, gin.H{ "user": gin.H{ - "username": user.Username, + "id": user.ID, "email": user.Email, + "username": user.Username, "type": user.Type, }, - "authorized": true, + "message": "Login successful", }) } diff --git a/api/internal/server/helper/token.go b/api/internal/server/helper/token.go new file mode 100644 index 0000000..b647741 --- /dev/null +++ b/api/internal/server/helper/token.go @@ -0,0 +1,17 @@ +package helper + +import ( + "Codex-Backend/api/internal/domain" + + "github.com/gin-gonic/gin" +) + +// Helper function to get user from context +func GetUserFromContext(c *gin.Context) (*domain.User, bool) { + if user, exists := c.Get("user"); exists { + if u, ok := user.(*domain.User); ok { + return u, true + } + } + return nil, false +} diff --git a/api/internal/interfaces/rest/middleware/is_admin.go b/api/internal/server/middleware/is_admin.go similarity index 100% rename from api/internal/interfaces/rest/middleware/is_admin.go rename to api/internal/server/middleware/is_admin.go diff --git a/api/internal/interfaces/rest/middleware/rate_limiter.go b/api/internal/server/middleware/rate_limiter.go similarity index 100% rename from api/internal/interfaces/rest/middleware/rate_limiter.go rename to api/internal/server/middleware/rate_limiter.go diff --git a/api/internal/server/middleware/token.go b/api/internal/server/middleware/token.go new file mode 100644 index 0000000..90771e3 --- /dev/null +++ b/api/internal/server/middleware/token.go @@ -0,0 +1,157 @@ +package firestore_middleware + +import ( + cmn "Codex-Backend/api/common" + "Codex-Backend/api/internal/domain" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ValidateToken creates a JWT validation middleware with configurable options +func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Extract token + tokenString, err := extractToken(c) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Missing or invalid authorization token", + }) + return + } + + // Parse and validate JWT + claims, err := parseAndValidateJWT(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid token: " + err.Error(), + }) + return + } + + // Set claims in context (always available) + c.Set("claims", claims) + c.Set("user_id", claims.ID) + c.Set("user_email", claims.Email) + + // Skip user lookup if not needed (for performance) + if config.SkipUserLookup { + c.Next() + return + } + + // Optional: Check cache first + var user *domain.User + cacheKey := fmt.Sprintf("user:%s", claims.ID) + + if config.Cache != nil { + if cached, found := config.Cache.Get(cacheKey); found { + if cachedUser, ok := cached.(*domain.User); ok { + user = cachedUser + } + } + } + + // Fetch user if not in cache + if user == nil { + user, err = config.UserService.GetUserByID(c.Request.Context(), claims.ID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "User verification failed", + }) + return + } + + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "User not found", + }) + return + } + + // Cache user if cache is available + if config.Cache != nil { + config.Cache.Set(cacheKey, user, config.CacheDuration) + } + } + + // Set user in context + c.Set("user", user) + c.Next() + } +} + +// extractToken extracts JWT token from cookie or Authorization header +func extractToken(c *gin.Context) (string, error) { + // Try cookie first + if tokenString, err := c.Cookie("Authorization"); err == nil && tokenString != "" { + return tokenString, nil + } + + // Try Authorization header as fallback + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + return "", fmt.Errorf("no authorization token provided") + } + + // Handle "Bearer " format + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + return authHeader[7:], nil + } + + return authHeader, nil +} + +// parseAndValidateJWT parses and validates the JWT token +func parseAndValidateJWT(tokenString string) (*domain.Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &domain.Claims{}, func(token *jwt.Token) (any, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Get signing key (consider using a key rotation system) + key := cmn.GetEnvVariable("JWT_SIGN_KEY") + if key == "" { + return nil, fmt.Errorf("JWT signing key not configured") + } + return []byte(key), nil + }) + + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*domain.Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token claims") + } + + // Validate required fields + if claims.ID == "" { + return nil, fmt.Errorf("user ID not found in token") + } + if claims.Email == "" { + return nil, fmt.Errorf("email not found in token") + } + + return claims, nil +} + +func AuthenticateOnly() gin.HandlerFunc { + return ValidateToken(domain.MiddlewareConfig{ + SkipUserLookup: true, + }) +} + +func AuthenticateAndLoadUser(userService domain.UserService, cache domain.TokenCache) gin.HandlerFunc { + return ValidateToken(domain.MiddlewareConfig{ + UserService: userService, + Cache: cache, + CacheDuration: 15 * time.Minute, + SkipUserLookup: false, + }) +} diff --git a/api/internal/interfaces/rest/routes.go b/api/internal/server/routes.go similarity index 55% rename from api/internal/interfaces/rest/routes.go rename to api/internal/server/routes.go index 782d6c9..537b25f 100644 --- a/api/internal/interfaces/rest/routes.go +++ b/api/internal/server/routes.go @@ -1,23 +1,23 @@ package firestore_server import ( - cmn "Codex-Backend/api/internal/common" - firestore_handlers "Codex-Backend/api/internal/interfaces/rest/handlers" - firestore_middleware "Codex-Backend/api/internal/interfaces/rest/middleware" + cmn "Codex-Backend/api/common" + firestore_handlers "Codex-Backend/api/internal/server/handlers" + firestore_middleware "Codex-Backend/api/internal/server/middleware" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) func RegisteredRoutes(r *gin.Engine) { - domain := cmn.GetEnvVariable("DOMAIN") - if gin.Mode() == gin.DebugMode && domain == "" { - domain = "*" + domain_url := cmn.GetEnvVariable("DOMAIN") + if gin.Mode() == gin.DebugMode && domain_url == "" { + domain_url = "*" } r.Use(cors.New(cors.Config{ AllowOrigins: []string{ - domain, + domain_url, }, AllowMethods: []string{ "GET", @@ -62,25 +62,27 @@ func RegisteredRoutes(r *gin.Engine) { manage := r.Group("/manage") { + // Create - manage.POST("/novel", firestore_middleware.ValidateToken(), firestore_handlers.CreateNovel) - manage.POST("/:novel/chapter", firestore_middleware.ValidateToken(), firestore_handlers.CreateChapter) - manage.POST("/epub", firestore_middleware.ValidateToken(), firestore_handlers.EPUBNovel) + manage.POST("/novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.CreateNovel) + manage.POST("/:novel/chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.CreateChapter) + manage.POST("/epub", firestore_middleware.AuthenticateOnly(), firestore_handlers.EPUBNovel) // Update - manage.PUT("/:novel", firestore_middleware.ValidateToken(), firestore_handlers.UpdateNovel) - manage.PUT("/:novel/:chapter", firestore_middleware.ValidateToken(), firestore_handlers.UpdateChapter) + manage.PUT("/:novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.UpdateNovel) + manage.PUT("/:novel/:chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.UpdateChapter) // Delete - manage.DELETE("/:novel", firestore_middleware.ValidateToken(), firestore_handlers.DeleteNovel) - manage.DELETE("/:novel/:chapter", firestore_middleware.ValidateToken(), firestore_handlers.DeleteChapter) + manage.DELETE("/:novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.DeleteNovel) + manage.DELETE("/:novel/:chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.DeleteChapter) } user := r.Group("/user") { - user.GET("/validate", firestore_middleware.ValidateToken(), firestore_handlers.ValidateToken) + user.GET("/validate", firestore_middleware.AuthenticateOnly(), firestore_handlers.ValidateToken) user.POST("/login", firestore_handlers.LoginUser) user.POST("/logout", firestore_handlers.LogoutUser) user.POST("/register", firestore_handlers.RegisterUser) + // user.GET("/refresh", firestore_middleware.AuthenticateOnly()) } } diff --git a/api/internal/interfaces/rest/server.go b/api/internal/server/server.go similarity index 96% rename from api/internal/interfaces/rest/server.go rename to api/internal/server/server.go index 1740f26..d310879 100644 --- a/api/internal/interfaces/rest/server.go +++ b/api/internal/server/server.go @@ -1,7 +1,7 @@ package firestore_server import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" "context" "log" "net/http" diff --git a/api/internal/usecases/collections/chapters.go b/api/internal/services/collections/chapters.go similarity index 94% rename from api/internal/usecases/collections/chapters.go rename to api/internal/services/collections/chapters.go index 57bf043..2d91cdb 100644 --- a/api/internal/usecases/collections/chapters.go +++ b/api/internal/services/collections/chapters.go @@ -1,10 +1,10 @@ package firestore_services import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" + firestore_client "Codex-Backend/api/internal/database/client" + firestore_collections "Codex-Backend/api/internal/database/collections" "Codex-Backend/api/internal/domain" - firestore_client "Codex-Backend/api/internal/infrastructure/client" - firestore_collections "Codex-Backend/api/internal/infrastructure/collections" "context" "errors" "net/http" diff --git a/api/internal/usecases/collections/novels.go b/api/internal/services/collections/novels.go similarity index 96% rename from api/internal/usecases/collections/novels.go rename to api/internal/services/collections/novels.go index 014e191..6b333a0 100644 --- a/api/internal/usecases/collections/novels.go +++ b/api/internal/services/collections/novels.go @@ -1,10 +1,10 @@ package firestore_services import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" + firestore_client "Codex-Backend/api/internal/database/client" + firestore_collections "Codex-Backend/api/internal/database/collections" "Codex-Backend/api/internal/domain" - firestore_client "Codex-Backend/api/internal/infrastructure/client" - firestore_collections "Codex-Backend/api/internal/infrastructure/collections" "context" "errors" "net/http" diff --git a/api/internal/usecases/collections/user.go b/api/internal/services/collections/user.go similarity index 72% rename from api/internal/usecases/collections/user.go rename to api/internal/services/collections/user.go index 9c79fd8..07574aa 100644 --- a/api/internal/usecases/collections/user.go +++ b/api/internal/services/collections/user.go @@ -1,20 +1,20 @@ package firestore_services import ( - cmn "Codex-Backend/api/internal/common" + cmn "Codex-Backend/api/common" + firestore_client "Codex-Backend/api/internal/database/client" + firestore_collections "Codex-Backend/api/internal/database/collections" "Codex-Backend/api/internal/domain" - firestore_client "Codex-Backend/api/internal/infrastructure/client" - firestore_collections "Codex-Backend/api/internal/infrastructure/collections" "context" "errors" "net/http" "time" ) -func LoginUser(credentials domain.Credentials, ctx context.Context) (string, *domain.User, error) { +func LoginUser(credentials domain.Credentials, ctx context.Context) (*domain.User, error) { client, err := firestore_client.FirestoreClient() if err != nil { - return "", nil, err + return nil, err } defer client.Close() @@ -22,24 +22,40 @@ func LoginUser(credentials domain.Credentials, ctx context.Context) (string, *do user, err := c.GetUserByEmail(credentials.Email, ctx) if err != nil { - return "", nil, err + return nil, err } if user == nil { - return "", nil, &cmn.Error{Err: errors.New("Login Service Error - User not found"), Status: http.StatusNotFound} + return nil, &cmn.Error{Err: errors.New("Login Service Error - User not found"), Status: http.StatusNotFound} } err = cmn.VerifyPassword(user.Password, credentials.Password) if err != nil { - return "", nil, &cmn.Error{Err: errors.New("Login Service Error - Invalid password"), Status: http.StatusUnauthorized} + return nil, &cmn.Error{Err: errors.New("Login Service Error - Invalid password"), Status: http.StatusUnauthorized} } - token, err := cmn.GenerateToken(credentials.Email) + return user, nil +} + +func GetUserByID(id string, ctx context.Context) (*domain.User, error) { + client, err := firestore_client.FirestoreClient() + if err != nil { + return nil, err + } + defer client.Close() + + c := firestore_collections.Client{Client: client} + + user, err := c.GetUserById(id, ctx) if err != nil { - return "", nil, err + return nil, err + } + + if user == nil { + return nil, &cmn.Error{Err: errors.New("Get User By ID Service Error - User not found"), Status: http.StatusNotFound} } - return token, user, nil + return user, nil } func RegisterUser(newUser domain.NewUser, ctx context.Context) error { diff --git a/api/internal/usecases/worker/args.go b/api/internal/services/worker/args.go similarity index 86% rename from api/internal/usecases/worker/args.go rename to api/internal/services/worker/args.go index 4b23265..2786cd6 100644 --- a/api/internal/usecases/worker/args.go +++ b/api/internal/services/worker/args.go @@ -1,7 +1,7 @@ package worker import ( - firestore_services "Codex-Backend/api/internal/usecases/collections" + firestore_services "Codex-Backend/api/internal/services/collections" "context" "github.com/riverqueue/river" From 302aa4c59c5495a1de3d245e936eadaeb6af857f Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Thu, 21 Aug 2025 00:18:19 +0400 Subject: [PATCH 02/18] Refactor: Streamlines package structure and introduces token cache Renames `handlers` to `handler` and `services` to `service` directories and packages for improved consistency. Removes `firestore_` prefixes from internal package names, simplifying the codebase. Introduces an in-memory cache for JWT token validation to optimize user lookup performance in authentication middleware. Decouples the `UserService` dependency from the JWT middleware configuration, relying directly on the `service` layer. --- api/common/river/client.go | 2 +- api/internal/domain/token.go | 12 +++- .../server/{handlers => handler}/chapters.go | 16 ++--- .../server/{handlers => handler}/novels.go | 18 ++--- .../server/{handlers => handler}/users.go | 10 +-- api/internal/server/middleware/is_admin.go | 2 +- .../server/middleware/rate_limiter.go | 2 +- .../server/middleware/token/cache_token.go | 72 +++++++++++++++++++ .../server/middleware/{ => token}/token.go | 11 ++- api/internal/server/routes.go | 41 +++++------ api/internal/server/server.go | 2 +- .../collections => service}/chapters.go | 2 +- .../collections => service}/novels.go | 2 +- .../{services/collections => service}/user.go | 2 +- .../{services => service}/worker/args.go | 4 +- 15 files changed, 140 insertions(+), 58 deletions(-) rename api/internal/server/{handlers => handler}/chapters.go (90%) rename api/internal/server/{handlers => handler}/novels.go (91%) rename api/internal/server/{handlers => handler}/users.go (93%) create mode 100644 api/internal/server/middleware/token/cache_token.go rename api/internal/server/middleware/{ => token}/token.go (92%) rename api/internal/{services/collections => service}/chapters.go (99%) rename api/internal/{services/collections => service}/novels.go (99%) rename api/internal/{services/collections => service}/user.go (99%) rename api/internal/{services => service}/worker/args.go (71%) diff --git a/api/common/river/client.go b/api/common/river/client.go index f3dbd20..20a7db8 100644 --- a/api/common/river/client.go +++ b/api/common/river/client.go @@ -1,7 +1,7 @@ package queue import ( - "Codex-Backend/api/internal/services/worker" + "Codex-Backend/api/internal/service/worker" "context" "log" "log/slog" diff --git a/api/internal/domain/token.go b/api/internal/domain/token.go index a8d349c..ce33664 100644 --- a/api/internal/domain/token.go +++ b/api/internal/domain/token.go @@ -2,6 +2,7 @@ package domain import ( "context" + "sync" "time" ) @@ -35,8 +36,17 @@ type UserService interface { // MiddlewareConfig holds configuration for the JWT middleware type MiddlewareConfig struct { - UserService UserService Cache TokenCache CacheDuration time.Duration SkipUserLookup bool } + +type InMemoryCache struct { + mu sync.RWMutex + items map[string]cacheItem +} + +type cacheItem struct { + value any + expiresAt time.Time +} diff --git a/api/internal/server/handlers/chapters.go b/api/internal/server/handler/chapters.go similarity index 90% rename from api/internal/server/handlers/chapters.go rename to api/internal/server/handler/chapters.go index d7a97e7..85d83c0 100644 --- a/api/internal/server/handlers/chapters.go +++ b/api/internal/server/handler/chapters.go @@ -1,9 +1,9 @@ -package firestore_handlers +package handler import ( cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/services/collections" + "Codex-Backend/api/internal/service" "net/http" "strconv" @@ -55,7 +55,7 @@ func GetPaginatedChapters(c *gin.Context) { } } - response, err := firestore_services.GetCursorPaginatedChapters(options, ctx) + response, err := service.GetCursorPaginatedChapters(options, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve chapters: " + e.Error(), @@ -88,7 +88,7 @@ func FindChapter(c *gin.Context) { return } - chapter, err := firestore_services.GetChapter(novelId, chapterId, ctx) + chapter, err := service.GetChapter(novelId, chapterId, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve chapter: " + e.Error(), @@ -119,7 +119,7 @@ func FindAllChapters(c *gin.Context) { return } - chapters, err := firestore_services.GetAllChapters(novelId, ctx) + chapters, err := service.GetAllChapters(novelId, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve chapters: " + e.Error(), @@ -152,7 +152,7 @@ func CreateChapter(c *gin.Context) { return } - err := firestore_services.CreateChapter(novelId, chapter, ctx) + err := service.CreateChapter(novelId, chapter, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to create chapter: " + e.Error(), @@ -185,7 +185,7 @@ func UpdateChapter(c *gin.Context) { return } - err := firestore_services.UpdateChapter(novelId, &chapter, ctx) + err := service.UpdateChapter(novelId, &chapter, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to update chapter: " + e.Error(), @@ -210,7 +210,7 @@ func DeleteChapter(c *gin.Context) { novelId := c.Param("novel") chapterId := c.Param("chapter") - err := firestore_services.DeleteChapter(novelId, chapterId, ctx) + err := service.DeleteChapter(novelId, chapterId, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to delete chapter: " + e.Error(), diff --git a/api/internal/server/handlers/novels.go b/api/internal/server/handler/novels.go similarity index 91% rename from api/internal/server/handlers/novels.go rename to api/internal/server/handler/novels.go index 31007b6..5532b77 100644 --- a/api/internal/server/handlers/novels.go +++ b/api/internal/server/handler/novels.go @@ -1,11 +1,11 @@ -package firestore_handlers +package handler import ( cmn "Codex-Backend/api/common" queue "Codex-Backend/api/common/river" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/services/collections" - "Codex-Backend/api/internal/services/worker" + "Codex-Backend/api/internal/service" + "Codex-Backend/api/internal/service/worker" "io" "net/http" "strings" @@ -80,7 +80,7 @@ func FindNovel(c *gin.Context) { } if withId { - novel, err := firestore_services.GetNovelById(param, ctx) + novel, err := service.GetNovelById(param, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve novel: " + e.Error(), @@ -97,7 +97,7 @@ func FindNovel(c *gin.Context) { "novel": novel, }) } else if withTitle { - novel, err := firestore_services.GetNovelByTitle(param, ctx) + novel, err := service.GetNovelByTitle(param, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve novel: " + e.Error(), @@ -125,7 +125,7 @@ func FindAllNovels(c *gin.Context) { ctx := c.Request.Context() defer ctx.Done() - novels, err := firestore_services.GetAllNovels(ctx) + novels, err := service.GetAllNovels(ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to retrieve novels: " + e.Error(), @@ -156,7 +156,7 @@ func CreateNovel(c *gin.Context) { return } - err, id := firestore_services.CreateNovel(novel, ctx) + err, id := service.CreateNovel(novel, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to create novel: " + e.Error(), @@ -196,7 +196,7 @@ func UpdateNovel(c *gin.Context) { return } - err := firestore_services.UpdateNovel(novelId, novel, ctx) + err := service.UpdateNovel(novelId, novel, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to update novel: " + e.Error(), @@ -226,7 +226,7 @@ func DeleteNovel(c *gin.Context) { return } - err := firestore_services.DeleteNovel(novelId, ctx) + err := service.DeleteNovel(novelId, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Failed to delete novel: " + e.Error(), diff --git a/api/internal/server/handlers/users.go b/api/internal/server/handler/users.go similarity index 93% rename from api/internal/server/handlers/users.go rename to api/internal/server/handler/users.go index a483bb5..2c65ba5 100644 --- a/api/internal/server/handlers/users.go +++ b/api/internal/server/handler/users.go @@ -1,9 +1,9 @@ -package firestore_handlers +package handler import ( cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" - firestore_services "Codex-Backend/api/internal/services/collections" + "Codex-Backend/api/internal/service" "net/http" "github.com/gin-gonic/gin" @@ -29,7 +29,7 @@ func RegisterUser(c *gin.Context) { return } - err := firestore_services.RegisterUser(user, ctx) + err := service.RegisterUser(user, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Error registering the user: " + e.Error(), @@ -67,7 +67,7 @@ func LoginUser(c *gin.Context) { return } - user, err := firestore_services.LoginUser(credentials, ctx) + user, err := service.LoginUser(credentials, ctx) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Error logging in the user: " + e.Error(), @@ -114,7 +114,7 @@ func LogoutUser(c *gin.Context) { return } - err = firestore_services.LogoutUser(tokenString) + err = service.LogoutUser(tokenString) if e, ok := err.(*cmn.Error); ok { c.AbortWithStatusJSON(e.StatusCode(), gin.H{ "error": "Error logging out the user: " + e.Error(), diff --git a/api/internal/server/middleware/is_admin.go b/api/internal/server/middleware/is_admin.go index 0d27f2e..91d7ca0 100644 --- a/api/internal/server/middleware/is_admin.go +++ b/api/internal/server/middleware/is_admin.go @@ -1,4 +1,4 @@ -package firestore_middleware +package middleware import ( "Codex-Backend/api/internal/domain" diff --git a/api/internal/server/middleware/rate_limiter.go b/api/internal/server/middleware/rate_limiter.go index e87367f..69caab0 100644 --- a/api/internal/server/middleware/rate_limiter.go +++ b/api/internal/server/middleware/rate_limiter.go @@ -1,4 +1,4 @@ -package firestore_middleware +package middleware import ( "net/http" diff --git a/api/internal/server/middleware/token/cache_token.go b/api/internal/server/middleware/token/cache_token.go new file mode 100644 index 0000000..5816b0f --- /dev/null +++ b/api/internal/server/middleware/token/cache_token.go @@ -0,0 +1,72 @@ +package token + +import ( + "sync" + "time" +) + +type InMemoryCache struct { + mu sync.RWMutex + items map[string]cacheItem +} + +type cacheItem struct { + value any + expiresAt time.Time +} + +func NewInMemoryCache() *InMemoryCache { + cache := &InMemoryCache{ + items: make(map[string]cacheItem), + } + + // Cleanup expired items every minute + go cache.cleanup() + + return cache +} + +func (c *InMemoryCache) Get(key string) (any, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists || time.Now().After(item.expiresAt) { + return nil, false + } + + return item.value, true +} + +func (c *InMemoryCache) Set(key string, value any, duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = cacheItem{ + value: value, + expiresAt: time.Now().Add(duration), + } +} + +func (c *InMemoryCache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) +} + +func (c *InMemoryCache) cleanup() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + for key, item := range c.items { + if now.After(item.expiresAt) { + delete(c.items, key) + } + } + c.mu.Unlock() + } +} diff --git a/api/internal/server/middleware/token.go b/api/internal/server/middleware/token/token.go similarity index 92% rename from api/internal/server/middleware/token.go rename to api/internal/server/middleware/token/token.go index 90771e3..dc53a78 100644 --- a/api/internal/server/middleware/token.go +++ b/api/internal/server/middleware/token/token.go @@ -1,8 +1,9 @@ -package firestore_middleware +package token import ( cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" + "Codex-Backend/api/internal/service" "fmt" "net/http" "time" @@ -43,7 +44,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { return } - // Optional: Check cache first + // Check cache first var user *domain.User cacheKey := fmt.Sprintf("user:%s", claims.ID) @@ -57,7 +58,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { // Fetch user if not in cache if user == nil { - user, err = config.UserService.GetUserByID(c.Request.Context(), claims.ID) + user, err = service.GetUserByID(claims.ID, c.Request.Context()) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "User verification failed", @@ -147,10 +148,8 @@ func AuthenticateOnly() gin.HandlerFunc { }) } -func AuthenticateAndLoadUser(userService domain.UserService, cache domain.TokenCache) gin.HandlerFunc { +func AuthenticateAndLoadUser() gin.HandlerFunc { return ValidateToken(domain.MiddlewareConfig{ - UserService: userService, - Cache: cache, CacheDuration: 15 * time.Minute, SkipUserLookup: false, }) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 537b25f..d18ea97 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -1,9 +1,10 @@ -package firestore_server +package server import ( cmn "Codex-Backend/api/common" - firestore_handlers "Codex-Backend/api/internal/server/handlers" - firestore_middleware "Codex-Backend/api/internal/server/middleware" + "Codex-Backend/api/internal/server/handler" + "Codex-Backend/api/internal/server/middleware" + "Codex-Backend/api/internal/server/middleware/token" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -48,41 +49,41 @@ func RegisteredRoutes(r *gin.Engine) { AllowCredentials: true, })) - r.Use(firestore_middleware.RateLimiter()) + r.Use(middleware.RateLimiter()) r.MaxMultipartMemory = 32 << 20 // 32 MB client := r.Group("/") { - client.GET("/all", firestore_handlers.FindAllNovels) - client.GET("/:novel", firestore_handlers.FindNovel) - client.GET("/:novel/all", firestore_handlers.FindAllChapters) - client.GET("/:novel/:chapter", firestore_handlers.FindChapter) - client.GET("/:novel/chapters", firestore_handlers.GetPaginatedChapters) + client.GET("/all", handler.FindAllNovels) + client.GET("/:novel", handler.FindNovel) + client.GET("/:novel/all", handler.FindAllChapters) + client.GET("/:novel/:chapter", handler.FindChapter) + client.GET("/:novel/chapters", handler.GetPaginatedChapters) } manage := r.Group("/manage") { // Create - manage.POST("/novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.CreateNovel) - manage.POST("/:novel/chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.CreateChapter) - manage.POST("/epub", firestore_middleware.AuthenticateOnly(), firestore_handlers.EPUBNovel) + manage.POST("/novel", token.AuthenticateOnly(), handler.CreateNovel) + manage.POST("/:novel/chapter", token.AuthenticateOnly(), handler.CreateChapter) + manage.POST("/epub", token.AuthenticateOnly(), handler.EPUBNovel) // Update - manage.PUT("/:novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.UpdateNovel) - manage.PUT("/:novel/:chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.UpdateChapter) + manage.PUT("/:novel", token.AuthenticateOnly(), handler.UpdateNovel) + manage.PUT("/:novel/:chapter", token.AuthenticateOnly(), handler.UpdateChapter) // Delete - manage.DELETE("/:novel", firestore_middleware.AuthenticateOnly(), firestore_handlers.DeleteNovel) - manage.DELETE("/:novel/:chapter", firestore_middleware.AuthenticateOnly(), firestore_handlers.DeleteChapter) + manage.DELETE("/:novel", token.AuthenticateOnly(), handler.DeleteNovel) + manage.DELETE("/:novel/:chapter", token.AuthenticateOnly(), handler.DeleteChapter) } user := r.Group("/user") { - user.GET("/validate", firestore_middleware.AuthenticateOnly(), firestore_handlers.ValidateToken) - user.POST("/login", firestore_handlers.LoginUser) - user.POST("/logout", firestore_handlers.LogoutUser) - user.POST("/register", firestore_handlers.RegisterUser) + user.GET("/validate", token.AuthenticateOnly(), handler.ValidateToken) + user.POST("/login", handler.LoginUser) + user.POST("/logout", handler.LogoutUser) + user.POST("/register", handler.RegisterUser) // user.GET("/refresh", firestore_middleware.AuthenticateOnly()) } } diff --git a/api/internal/server/server.go b/api/internal/server/server.go index d310879..b0e6940 100644 --- a/api/internal/server/server.go +++ b/api/internal/server/server.go @@ -1,4 +1,4 @@ -package firestore_server +package server import ( cmn "Codex-Backend/api/common" diff --git a/api/internal/services/collections/chapters.go b/api/internal/service/chapters.go similarity index 99% rename from api/internal/services/collections/chapters.go rename to api/internal/service/chapters.go index 2d91cdb..7a77d79 100644 --- a/api/internal/services/collections/chapters.go +++ b/api/internal/service/chapters.go @@ -1,4 +1,4 @@ -package firestore_services +package service import ( cmn "Codex-Backend/api/common" diff --git a/api/internal/services/collections/novels.go b/api/internal/service/novels.go similarity index 99% rename from api/internal/services/collections/novels.go rename to api/internal/service/novels.go index 6b333a0..49a0dff 100644 --- a/api/internal/services/collections/novels.go +++ b/api/internal/service/novels.go @@ -1,4 +1,4 @@ -package firestore_services +package service import ( cmn "Codex-Backend/api/common" diff --git a/api/internal/services/collections/user.go b/api/internal/service/user.go similarity index 99% rename from api/internal/services/collections/user.go rename to api/internal/service/user.go index 07574aa..9c561f1 100644 --- a/api/internal/services/collections/user.go +++ b/api/internal/service/user.go @@ -1,4 +1,4 @@ -package firestore_services +package service import ( cmn "Codex-Backend/api/common" diff --git a/api/internal/services/worker/args.go b/api/internal/service/worker/args.go similarity index 71% rename from api/internal/services/worker/args.go rename to api/internal/service/worker/args.go index 2786cd6..95cdcee 100644 --- a/api/internal/services/worker/args.go +++ b/api/internal/service/worker/args.go @@ -1,7 +1,7 @@ package worker import ( - firestore_services "Codex-Backend/api/internal/services/collections" + "Codex-Backend/api/internal/service" "context" "github.com/riverqueue/river" @@ -12,7 +12,7 @@ type EPUBWorker struct { } func (w *EPUBWorker) Work(ctx context.Context, job *river.Job[ProcessEPUBArgs]) error { - return firestore_services.CreateNovelFromEPUB(job.Args.File, ctx) + return service.CreateNovelFromEPUB(job.Args.File, ctx) } type ProcessEPUBArgs struct { From b4e77fbe5ffa558d9c159cbfc213c2681da1d0e0 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Thu, 21 Aug 2025 01:21:32 +0400 Subject: [PATCH 03/18] Refactors JWT token handling Centralizes JWT claims definition and adds user type field. Simplifies authentication middleware by removing direct user database lookup, focusing on claims validation. Introduces a global in-memory token cache for efficient management and blacklisting. Standardizes access token cookie and header names to `access_token`. Updates token validation endpoint to return claims data directly. Increases token cache cleanup interval for improved performance. --- api/internal/domain/token.go | 16 +++++++++------- api/internal/domain/user.go | 8 -------- api/internal/server/handler/users.go | 12 ++++-------- .../server/middleware/token/cache_token.go | 19 ++++++++++++++++++- api/internal/server/middleware/token/token.go | 18 ++++++++++-------- api/internal/server/routes.go | 6 ++++-- 6 files changed, 45 insertions(+), 34 deletions(-) diff --git a/api/internal/domain/token.go b/api/internal/domain/token.go index ce33664..e49102f 100644 --- a/api/internal/domain/token.go +++ b/api/internal/domain/token.go @@ -1,11 +1,19 @@ package domain import ( - "context" "sync" "time" + + "github.com/golang-jwt/jwt/v5" ) +type Claims struct { + ID string `json:"id"` + Email string `json:"email"` + Type string `json:"type"` + jwt.RegisteredClaims +} + type TokenConfig struct { SigningKey string AccessTTL time.Duration @@ -28,12 +36,6 @@ type TokenCache interface { Delete(key string) } -// UserService interface for user operations -type UserService interface { - GetUserByID(ctx context.Context, userID string) (*User, error) - IsUserActive(ctx context.Context, userID string) (bool, error) -} - // MiddlewareConfig holds configuration for the JWT middleware type MiddlewareConfig struct { Cache TokenCache diff --git a/api/internal/domain/user.go b/api/internal/domain/user.go index 0d66c3b..611dfdc 100644 --- a/api/internal/domain/user.go +++ b/api/internal/domain/user.go @@ -1,7 +1,5 @@ package domain -import "github.com/golang-jwt/jwt/v5" - type User struct { ID string `json:"id"` Username string `json:"username"` @@ -17,12 +15,6 @@ type Credentials struct { Password string `json:"password"` } -type Claims struct { - ID string `json:"id"` - Email string `json:"email"` - jwt.RegisteredClaims -} - type NewUser struct { Username string `json:"username"` Password string `json:"password"` diff --git a/api/internal/server/handler/users.go b/api/internal/server/handler/users.go index 2c65ba5..4e6ffe8 100644 --- a/api/internal/server/handler/users.go +++ b/api/internal/server/handler/users.go @@ -135,7 +135,7 @@ func LogoutUser(c *gin.Context) { } func ValidateToken(c *gin.Context) { - result, ok := c.Get("user") + result_claims, ok := c.Get("claims") if !ok { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ "error": "User not found", @@ -143,7 +143,7 @@ func ValidateToken(c *gin.Context) { return } - user, ok := result.(*domain.User) + claims, ok := result_claims.(*domain.Claims) if !ok { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": "Invalid user structure", @@ -152,11 +152,7 @@ func ValidateToken(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "user": gin.H{ - "username": user.Username, - "email": user.Email, - "type": user.Type, - }, - "authenticated": true, + "id": claims.ID, + "email": claims.Email, }) } diff --git a/api/internal/server/middleware/token/cache_token.go b/api/internal/server/middleware/token/cache_token.go index 5816b0f..4971590 100644 --- a/api/internal/server/middleware/token/cache_token.go +++ b/api/internal/server/middleware/token/cache_token.go @@ -1,6 +1,7 @@ package token import ( + "Codex-Backend/api/internal/domain" "sync" "time" ) @@ -56,7 +57,7 @@ func (c *InMemoryCache) Delete(key string) { } func (c *InMemoryCache) cleanup() { - ticker := time.NewTicker(1 * time.Minute) + ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { @@ -70,3 +71,19 @@ func (c *InMemoryCache) cleanup() { c.mu.Unlock() } } + +type IMTokenCache struct { + cache domain.TokenCache +} + +var GlobalToken *IMTokenCache + +func NewIMTokenCache(cache domain.TokenCache) *IMTokenCache { + return &IMTokenCache{ + cache: cache, + } +} + +func InitIMTokenCache() { + GlobalToken = NewIMTokenCache(NewInMemoryCache()) +} diff --git a/api/internal/server/middleware/token/token.go b/api/internal/server/middleware/token/token.go index dc53a78..c19018a 100644 --- a/api/internal/server/middleware/token/token.go +++ b/api/internal/server/middleware/token/token.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "cloud.google.com/go/firestore" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) @@ -37,6 +38,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { c.Set("claims", claims) c.Set("user_id", claims.ID) c.Set("user_email", claims.Email) + c.Set("user_type", claims.Type) // Skip user lookup if not needed (for performance) if config.SkipUserLookup { @@ -88,12 +90,12 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { // extractToken extracts JWT token from cookie or Authorization header func extractToken(c *gin.Context) (string, error) { // Try cookie first - if tokenString, err := c.Cookie("Authorization"); err == nil && tokenString != "" { + if tokenString, err := c.Cookie("access_token"); err == nil && tokenString != "" { return tokenString, nil } // Try Authorization header as fallback - authHeader := c.GetHeader("Authorization") + authHeader := c.GetHeader("access_token") if authHeader == "" { return "", fmt.Errorf("no authorization token provided") } @@ -109,16 +111,11 @@ func extractToken(c *gin.Context) (string, error) { // parseAndValidateJWT parses and validates the JWT token func parseAndValidateJWT(tokenString string) (*domain.Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &domain.Claims{}, func(token *jwt.Token) (any, error) { - // Validate signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - // Get signing key (consider using a key rotation system) key := cmn.GetEnvVariable("JWT_SIGN_KEY") - if key == "" { - return nil, fmt.Errorf("JWT signing key not configured") - } return []byte(key), nil }) @@ -148,8 +145,13 @@ func AuthenticateOnly() gin.HandlerFunc { }) } -func AuthenticateAndLoadUser() gin.HandlerFunc { +type FirestoreUserService struct { + client *firestore.Client +} + +func (mf *IMTokenCache) AuthenticateAndLoadUser() gin.HandlerFunc { return ValidateToken(domain.MiddlewareConfig{ + Cache: mf.cache, CacheDuration: 15 * time.Minute, SkipUserLookup: false, }) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index d18ea97..3fbdcb4 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -52,6 +52,8 @@ func RegisteredRoutes(r *gin.Engine) { r.Use(middleware.RateLimiter()) r.MaxMultipartMemory = 32 << 20 // 32 MB + token.InitIMTokenCache() + client := r.Group("/") { client.GET("/all", handler.FindAllNovels) @@ -80,10 +82,10 @@ func RegisteredRoutes(r *gin.Engine) { user := r.Group("/user") { - user.GET("/validate", token.AuthenticateOnly(), handler.ValidateToken) + user.GET("/validate", token.GlobalToken.AuthenticateAndLoadUser(), handler.ValidateToken) user.POST("/login", handler.LoginUser) user.POST("/logout", handler.LogoutUser) user.POST("/register", handler.RegisterUser) - // user.GET("/refresh", firestore_middleware.AuthenticateOnly()) + user.GET("/refresh", token.AuthenticateOnly()) } } From 64cc6610666bb9a189393b9b588c898478b9613c Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Thu, 21 Aug 2025 03:10:40 +0400 Subject: [PATCH 04/18] Refactor: Move token generation to `middleware/token` package --- api/internal/server/handler/users.go | 9 ++++---- .../server/middleware/token/generate.go} | 23 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) rename api/{common/token.go => internal/server/middleware/token/generate.go} (78%) diff --git a/api/internal/server/handler/users.go b/api/internal/server/handler/users.go index 4e6ffe8..0325af2 100644 --- a/api/internal/server/handler/users.go +++ b/api/internal/server/handler/users.go @@ -3,6 +3,7 @@ package handler import ( cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" + "Codex-Backend/api/internal/server/middleware/token" "Codex-Backend/api/internal/service" "net/http" @@ -80,9 +81,9 @@ func LoginUser(c *gin.Context) { return } - config := cmn.DefaultTokenConfig() + config := token.DefaultTokenConfig() - tokens, err := cmn.GenerateTokenPair(user.ID, user.Email, config) + tokens, err := token.GenerateTokenPair(user.ID, user.Email, config) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "Error logging in the user: " + err.Error(), @@ -91,8 +92,8 @@ func LoginUser(c *gin.Context) { } c.SetSameSite(http.SameSiteStrictMode) - c.SetCookie("access_token", tokens.AccessToken, 15*60, "/", "", true, true) - c.SetCookie("refresh_token", tokens.RefreshToken, 7*24*3600, "/auth", "", true, true) + c.SetCookie("access_token", tokens.AccessToken, int(config.AccessTTL.Seconds()), "/", "", true, true) + c.SetCookie("refresh_token", tokens.RefreshToken, int(config.RefreshTTL.Seconds()), "/", "", true, true) c.JSON(http.StatusOK, gin.H{ "user": gin.H{ diff --git a/api/common/token.go b/api/internal/server/middleware/token/generate.go similarity index 78% rename from api/common/token.go rename to api/internal/server/middleware/token/generate.go index 6de576f..d43cf7c 100644 --- a/api/common/token.go +++ b/api/internal/server/middleware/token/generate.go @@ -1,6 +1,7 @@ -package common +package token import ( + cmn "Codex-Backend/api/common" "Codex-Backend/api/internal/domain" "crypto/rand" "encoding/hex" @@ -12,13 +13,13 @@ import ( func GenerateTokenPair(ID, email string, config domain.TokenConfig) (*domain.TokenPair, error) { if ID == "" { - return nil, &Error{Err: errors.New("user ID cannot be empty")} + return nil, &cmn.Error{Err: errors.New("user ID cannot be empty")} } if email == "" { - return nil, &Error{Err: errors.New("email cannot be empty")} + return nil, &cmn.Error{Err: errors.New("email cannot be empty")} } if config.SigningKey == "" { - return nil, &Error{Err: errors.New("signing key not configured")} + return nil, &cmn.Error{Err: errors.New("signing key not configured")} } // Generate access token @@ -54,7 +55,7 @@ func generateAccessToken(ID, email string, config domain.TokenConfig) (string, t // Generate unique token ID for revocation capability jti, err := generateJTI() if err != nil { - return "", time.Time{}, &Error{Err: errors.New("failed to generate token ID: " + err.Error())} + return "", time.Time{}, &cmn.Error{Err: errors.New("failed to generate token ID: " + err.Error())} } claims := &domain.Claims{ @@ -74,7 +75,7 @@ func generateAccessToken(ID, email string, config domain.TokenConfig) (string, t token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(config.SigningKey)) if err != nil { - return "", time.Time{}, &Error{Err: errors.New("token signing failed: " + err.Error())} + return "", time.Time{}, &cmn.Error{Err: errors.New("token signing failed: " + err.Error())} } return tokenString, expirationTime, nil @@ -86,7 +87,7 @@ func generateRefreshToken(userID string, config domain.TokenConfig) (string, err jti, err := generateJTI() if err != nil { - return "", &Error{Err: errors.New("failed to generate refresh token ID: " + err.Error())} + return "", &cmn.Error{Err: errors.New("failed to generate refresh token ID: " + err.Error())} } // Refresh tokens have minimal claims @@ -103,7 +104,7 @@ func generateRefreshToken(userID string, config domain.TokenConfig) (string, err token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(config.SigningKey)) if err != nil { - return "", &Error{Err: errors.New("refresh token signing failed: " + err.Error())} + return "", &cmn.Error{Err: errors.New("refresh token signing failed: " + err.Error())} } return tokenString, nil @@ -120,10 +121,10 @@ func generateJTI() (string, error) { func DefaultTokenConfig() domain.TokenConfig { return domain.TokenConfig{ - SigningKey: GetEnvVariable("JWT_SIGN_KEY"), + SigningKey: cmn.GetEnvVariable("JWT_SIGN_KEY"), AccessTTL: 30 * time.Minute, RefreshTTL: 7 * 24 * time.Hour, - Issuer: GetEnvVariable("JWT_ISSUER"), - Audience: GetEnvVariable("JWT_AUDIENCE"), + Issuer: cmn.GetEnvVariable("JWT_ISSUER"), + Audience: cmn.GetEnvVariable("JWT_AUDIENCE"), } } From 7763590459f1f5779afdfe009d04b6022a9b02d9 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Thu, 21 Aug 2025 03:10:40 +0400 Subject: [PATCH 05/18] Feat: Implement automatic token refresh and refresh token endpoint --- api/internal/server/handler/token.go | 97 +++++++++++++++ .../server/middleware/token/refresh_token.go | 117 ++++++++++++++++++ api/internal/server/routes.go | 6 +- 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 api/internal/server/handler/token.go create mode 100644 api/internal/server/middleware/token/refresh_token.go diff --git a/api/internal/server/handler/token.go b/api/internal/server/handler/token.go new file mode 100644 index 0000000..1e2339d --- /dev/null +++ b/api/internal/server/handler/token.go @@ -0,0 +1,97 @@ +package handler + +import ( + cmn "Codex-Backend/api/common" + "Codex-Backend/api/internal/domain" + token_middleware "Codex-Backend/api/internal/server/middleware/token" + "Codex-Backend/api/internal/service" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func RefreshToken(c *gin.Context) { + ctx := c.Request.Context() + defer ctx.Done() + + refreshToken, err := c.Cookie("refresh_token") + if err != nil { + c.JSON(401, gin.H{"error": "No refresh token provided"}) + return + } + + config := token_middleware.DefaultTokenConfig() + token, err := jwt.ParseWithClaims(refreshToken, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing method") + } + return []byte(config.SigningKey), nil + }) + + if err != nil { + c.JSON(401, gin.H{"error": "Invalid refresh token"}) + return + } + + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !ok || !token.Valid { + c.JSON(401, gin.H{"error": "Invalid refresh token claims"}) + return + } + + user, err := service.GetUserByID(claims.Subject, ctx) + if e, ok := err.(*cmn.Error); ok { + c.AbortWithStatusJSON(e.StatusCode(), gin.H{ + "error": "User not found: " + e.Error(), + }) + return + } else if err != nil { + c.AbortWithStatusJSON(401, gin.H{ + "error": "User not found: " + err.Error(), + }) + return + } + + // Generate new token pair + tokens, err := token_middleware.GenerateTokenPair(user.ID, user.Email, config) + if err != nil { + c.JSON(500, gin.H{"error": "Token generation failed"}) + return + } + + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("access_token", tokens.AccessToken, int(config.AccessTTL.Seconds()), "/", "", true, true) + c.SetCookie("refresh_token", tokens.RefreshToken, int(config.RefreshTTL.Seconds()), "/", "", true, true) + + c.JSON(200, gin.H{ + "message": "Tokens refreshed successfully", + "expires_at": tokens.ExpiresAt, + "expires_in": int(config.AccessTTL.Seconds()), + }) + +} + +func ValidateToken(c *gin.Context) { + result_claims, ok := c.Get("claims") + if !ok { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ + "error": "User not found", + }) + return + } + + claims, ok := result_claims.(*domain.Claims) + if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "Invalid user structure", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": claims.ID, + "email": claims.Email, + }) +} diff --git a/api/internal/server/middleware/token/refresh_token.go b/api/internal/server/middleware/token/refresh_token.go new file mode 100644 index 0000000..e37c4b2 --- /dev/null +++ b/api/internal/server/middleware/token/refresh_token.go @@ -0,0 +1,117 @@ +package token + +import ( + "Codex-Backend/api/internal/domain" + "Codex-Backend/api/internal/service" + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func (mf *IMTokenCache) AutoRefreshTokenMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + defer ctx.Done() + + path := c.Request.URL.Path + if path == "/user/refresh" || path == "/user/logout" { + c.Next() + return + } + + // Check if access token is close to expiring + claims, exists := c.Get("claims") + if !exists { + c.Next() + return + } + + userClaims, ok := claims.(*domain.Claims) + if !ok { + c.Next() + return + } + + // Refresh if less than 5 minutes remaining on access token + timeUntilExpiry := time.Until(userClaims.ExpiresAt.Time) + if timeUntilExpiry > 5*time.Minute { + c.Next() + return + } + + // Access token expires soon, try to refresh using refresh token + refreshToken, err := c.Cookie("refresh_token") + if err != nil { + // No refresh token available, let it expire naturally + c.Next() + return + } + + config := DefaultTokenConfig() + + // Generate new access token using refresh token + newAccessToken, err := refreshAccessTokenFromString(refreshToken, userClaims.ID, domain.MiddlewareConfig{ + Cache: mf.cache, + CacheDuration: 1 * time.Hour, + }, ctx) + if err != nil { + c.Next() + return + } + + // Set new access token cookie + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("access_token", newAccessToken, int(config.AccessTTL.Seconds()), "/", "", true, true) + + // Let frontend know token was refreshed + c.Header("X-Token-Refreshed", "true") + + c.Next() + } +} + +func refreshAccessTokenFromString(refreshTokenString, expectedUserID string, cacheConfig domain.MiddlewareConfig, ctx context.Context) (string, error) { + config := DefaultTokenConfig() + + // Parse refresh token + token, err := jwt.ParseWithClaims(refreshTokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(config.SigningKey), nil + }) + + if err != nil { + return "", err + } + + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !ok || !token.Valid { + return "", errors.New("invalid refresh token claims") + } + + if claims.Subject != expectedUserID { + return "", errors.New("refresh token user mismatch") + } + + // Get user info (from cache or database) + user, err := service.GetUserByID(claims.ID, ctx) + if err != nil { + return "", err + } + + // Cache user if cache is available + cacheKey := fmt.Sprintf("user:%s", claims.ID) + if cacheConfig.Cache != nil { + cacheConfig.Cache.Set(cacheKey, user, cacheConfig.CacheDuration) + } + + // Generate new access token + newAccessToken, _, err := generateAccessToken(user.ID, user.Email, config) + return newAccessToken, err +} diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 3fbdcb4..838731f 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -54,6 +54,8 @@ func RegisteredRoutes(r *gin.Engine) { token.InitIMTokenCache() + r.Use(token.AuthenticateOnly(), token.GlobalToken.AutoRefreshTokenMiddleware()) + client := r.Group("/") { client.GET("/all", handler.FindAllNovels) @@ -82,10 +84,10 @@ func RegisteredRoutes(r *gin.Engine) { user := r.Group("/user") { - user.GET("/validate", token.GlobalToken.AuthenticateAndLoadUser(), handler.ValidateToken) + user.GET("/validate", handler.ValidateToken) user.POST("/login", handler.LoginUser) user.POST("/logout", handler.LogoutUser) user.POST("/register", handler.RegisterUser) - user.GET("/refresh", token.AuthenticateOnly()) + user.GET("/refresh", handler.RefreshToken) } } From 6d71b50177f8fc8735a61130f5c1133659552e48 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Thu, 21 Aug 2025 03:10:40 +0400 Subject: [PATCH 06/18] Refactor: Streamline token validation middleware and handlers --- api/internal/server/handler/users.go | 26 ++----------------- api/internal/server/middleware/token/token.go | 9 +++---- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/api/internal/server/handler/users.go b/api/internal/server/handler/users.go index 0325af2..89880a0 100644 --- a/api/internal/server/handler/users.go +++ b/api/internal/server/handler/users.go @@ -128,32 +128,10 @@ func LogoutUser(c *gin.Context) { return } - c.SetCookie("Authorization", "", -1, "", "", true, true) + c.SetCookie("access_token", "", -1, "", "", true, true) + c.SetCookie("refresh_token", "", -1, "", "", true, true) c.JSON(http.StatusOK, gin.H{ "message": "Logged out successfully", }) } - -func ValidateToken(c *gin.Context) { - result_claims, ok := c.Get("claims") - if !ok { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ - "error": "User not found", - }) - return - } - - claims, ok := result_claims.(*domain.Claims) - if !ok { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ - "error": "Invalid user structure", - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "id": claims.ID, - "email": claims.Email, - }) -} diff --git a/api/internal/server/middleware/token/token.go b/api/internal/server/middleware/token/token.go index c19018a..1318f88 100644 --- a/api/internal/server/middleware/token/token.go +++ b/api/internal/server/middleware/token/token.go @@ -26,7 +26,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { } // Parse and validate JWT - claims, err := parseAndValidateJWT(tokenString) + claims, err := ParseAndValidateJWT(tokenString) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Invalid token: " + err.Error(), @@ -36,9 +36,6 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { // Set claims in context (always available) c.Set("claims", claims) - c.Set("user_id", claims.ID) - c.Set("user_email", claims.Email) - c.Set("user_type", claims.Type) // Skip user lookup if not needed (for performance) if config.SkipUserLookup { @@ -109,7 +106,7 @@ func extractToken(c *gin.Context) (string, error) { } // parseAndValidateJWT parses and validates the JWT token -func parseAndValidateJWT(tokenString string) (*domain.Claims, error) { +func ParseAndValidateJWT(tokenString string) (*domain.Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &domain.Claims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -152,7 +149,7 @@ type FirestoreUserService struct { func (mf *IMTokenCache) AuthenticateAndLoadUser() gin.HandlerFunc { return ValidateToken(domain.MiddlewareConfig{ Cache: mf.cache, - CacheDuration: 15 * time.Minute, + CacheDuration: 1 * time.Hour, SkipUserLookup: false, }) } From d3527cff6658ad6282df8532af865f6e6b54ea7e Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:52 +0400 Subject: [PATCH 07/18] chore: Update build artifact name in air.toml --- .air.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.air.toml b/.air.toml index 95c39d4..dc7433d 100644 --- a/.air.toml +++ b/.air.toml @@ -4,8 +4,8 @@ tmp_dir = "tmp" [build] args_bin = [] -bin = "./tmp/codex" -cmd = "go build -o ./tmp/codex ./api/cmd/codex" +bin = "./tmp/web" +cmd = "go build -o ./tmp/web ./api/cmd/web" delay = 1000 exclude_dir = [ ".git", From cf265d6eb02273fc6cd7e34b6bc4ffd111293b6c Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:52 +0400 Subject: [PATCH 08/18] chore: Exclude .github directory from air watch --- .air.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.air.toml b/.air.toml index dc7433d..6a48cc0 100644 --- a/.air.toml +++ b/.air.toml @@ -15,6 +15,7 @@ exclude_dir = [ "node_modules", ".vscode", ".idea", + ".github", ] exclude_file = [] exclude_regex = ["_test\\.go$", "\\.git", "\\.DS_Store"] From a709ac9fa4b5680b624ae5e65ba209fcdedb203a Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:52 +0400 Subject: [PATCH 09/18] feat(common): Add GetDomains utility function --- api/common/env.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/common/env.go b/api/common/env.go index 773e746..9c56cd0 100644 --- a/api/common/env.go +++ b/api/common/env.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/joho/godotenv" ) @@ -24,3 +25,14 @@ func GetEnvVariable(v string) string { return env_variable } + +func GetDomains(v string) []string { + env_variable := os.Getenv(v) + if env_variable == "" { + log.Fatal(&Error{Err: errors.New("Environmental Variable " + v + " Not Found"), Status: http.StatusNotFound}) + } + + result := strings.Split(env_variable, ",") + + return result +} From 67a7a60e8980a4a76bac4545e97dc773ad6c5321 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:52 +0400 Subject: [PATCH 10/18] refactor(domain): Rename token config struct to LookupUser --- api/internal/domain/token.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/api/internal/domain/token.go b/api/internal/domain/token.go index e49102f..162a1ef 100644 --- a/api/internal/domain/token.go +++ b/api/internal/domain/token.go @@ -36,11 +36,9 @@ type TokenCache interface { Delete(key string) } -// MiddlewareConfig holds configuration for the JWT middleware -type MiddlewareConfig struct { - Cache TokenCache - CacheDuration time.Duration - SkipUserLookup bool +type LookupUser struct { + Cache TokenCache + CacheDuration time.Duration } type InMemoryCache struct { From a1cfb8ac96bd7954a6aaab30f8640f4a56f85c1d Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:52 +0400 Subject: [PATCH 11/18] refactor(jwt): Use `any` type for jwt.ParseWithClaims callbacks --- api/internal/server/handler/token.go | 2 +- api/internal/server/middleware/token/refresh_token.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/internal/server/handler/token.go b/api/internal/server/handler/token.go index 1e2339d..b934a30 100644 --- a/api/internal/server/handler/token.go +++ b/api/internal/server/handler/token.go @@ -23,7 +23,7 @@ func RefreshToken(c *gin.Context) { } config := token_middleware.DefaultTokenConfig() - token, err := jwt.ParseWithClaims(refreshToken, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(refreshToken, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("invalid signing method") } diff --git a/api/internal/server/middleware/token/refresh_token.go b/api/internal/server/middleware/token/refresh_token.go index e37c4b2..06ffdb9 100644 --- a/api/internal/server/middleware/token/refresh_token.go +++ b/api/internal/server/middleware/token/refresh_token.go @@ -75,11 +75,11 @@ func (mf *IMTokenCache) AutoRefreshTokenMiddleware() gin.HandlerFunc { } } -func refreshAccessTokenFromString(refreshTokenString, expectedUserID string, cacheConfig domain.MiddlewareConfig, ctx context.Context) (string, error) { +func refreshAccessTokenFromString(refreshTokenString, expectedUserID string, cacheConfig domain.LookupUser, ctx context.Context) (string, error) { config := DefaultTokenConfig() // Parse refresh token - token, err := jwt.ParseWithClaims(refreshTokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(refreshTokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } From 4d3443037bdd74a15f473cbb68e57cb475a60416 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:53 +0400 Subject: [PATCH 12/18] refactor(middleware): Split token validation and user lookup --- api/internal/server/middleware/token/token.go | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/api/internal/server/middleware/token/token.go b/api/internal/server/middleware/token/token.go index 1318f88..293293c 100644 --- a/api/internal/server/middleware/token/token.go +++ b/api/internal/server/middleware/token/token.go @@ -13,11 +13,9 @@ import ( "github.com/golang-jwt/jwt/v5" ) -// ValidateToken creates a JWT validation middleware with configurable options -func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { +func SetClaimsFromToken() gin.HandlerFunc { return func(c *gin.Context) { - // Extract token - tokenString, err := extractToken(c) + tokenString, err := ExtractToken(c) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Missing or invalid authorization token", @@ -36,16 +34,27 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { // Set claims in context (always available) c.Set("claims", claims) + } +} + +// ValidateToken creates a JWT validation middleware with configurable options +func LookupUser(config domain.LookupUser) gin.HandlerFunc { + return func(c *gin.Context) { + claims, exists := c.Get("claims") + if !exists { + c.Next() + return + } - // Skip user lookup if not needed (for performance) - if config.SkipUserLookup { + userClaims, ok := claims.(*domain.Claims) + if !ok { c.Next() return } // Check cache first var user *domain.User - cacheKey := fmt.Sprintf("user:%s", claims.ID) + cacheKey := fmt.Sprintf("user:%s", userClaims.ID) if config.Cache != nil { if cached, found := config.Cache.Get(cacheKey); found { @@ -57,7 +66,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { // Fetch user if not in cache if user == nil { - user, err = service.GetUserByID(claims.ID, c.Request.Context()) + user, err := service.GetUserByID(userClaims.ID, c.Request.Context()) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "User verification failed", @@ -85,7 +94,7 @@ func ValidateToken(config domain.MiddlewareConfig) gin.HandlerFunc { } // extractToken extracts JWT token from cookie or Authorization header -func extractToken(c *gin.Context) (string, error) { +func ExtractToken(c *gin.Context) (string, error) { // Try cookie first if tokenString, err := c.Cookie("access_token"); err == nil && tokenString != "" { return tokenString, nil @@ -136,20 +145,13 @@ func ParseAndValidateJWT(tokenString string) (*domain.Claims, error) { return claims, nil } -func AuthenticateOnly() gin.HandlerFunc { - return ValidateToken(domain.MiddlewareConfig{ - SkipUserLookup: true, - }) -} - type FirestoreUserService struct { client *firestore.Client } -func (mf *IMTokenCache) AuthenticateAndLoadUser() gin.HandlerFunc { - return ValidateToken(domain.MiddlewareConfig{ - Cache: mf.cache, - CacheDuration: 1 * time.Hour, - SkipUserLookup: false, +func (mf *IMTokenCache) LoadUser() gin.HandlerFunc { + return LookupUser(domain.LookupUser{ + Cache: mf.cache, + CacheDuration: 1 * time.Hour, }) } From 8395362a001fe76df1cf6580057bb0f4ab2086e2 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:53 +0400 Subject: [PATCH 13/18] feat(middleware): Adapt token middleware usage to new design --- .../server/middleware/token/refresh_token.go | 2 +- api/internal/server/routes.go | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/internal/server/middleware/token/refresh_token.go b/api/internal/server/middleware/token/refresh_token.go index 06ffdb9..e5af331 100644 --- a/api/internal/server/middleware/token/refresh_token.go +++ b/api/internal/server/middleware/token/refresh_token.go @@ -55,7 +55,7 @@ func (mf *IMTokenCache) AutoRefreshTokenMiddleware() gin.HandlerFunc { config := DefaultTokenConfig() // Generate new access token using refresh token - newAccessToken, err := refreshAccessTokenFromString(refreshToken, userClaims.ID, domain.MiddlewareConfig{ + newAccessToken, err := refreshAccessTokenFromString(refreshToken, userClaims.ID, domain.LookupUser{ Cache: mf.cache, CacheDuration: 1 * time.Hour, }, ctx) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 838731f..93c6d9e 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -54,7 +54,8 @@ func RegisteredRoutes(r *gin.Engine) { token.InitIMTokenCache() - r.Use(token.AuthenticateOnly(), token.GlobalToken.AutoRefreshTokenMiddleware()) + // Add mandatory token check + r.Use(token.SetClaimsFromToken(), token.GlobalToken.AutoRefreshTokenMiddleware()) client := r.Group("/") { @@ -67,19 +68,20 @@ func RegisteredRoutes(r *gin.Engine) { manage := r.Group("/manage") { + manage.Use(token.GlobalToken.LoadUser()) // Create - manage.POST("/novel", token.AuthenticateOnly(), handler.CreateNovel) - manage.POST("/:novel/chapter", token.AuthenticateOnly(), handler.CreateChapter) - manage.POST("/epub", token.AuthenticateOnly(), handler.EPUBNovel) + manage.POST("/novel", handler.CreateNovel) + manage.POST("/:novel/chapter", handler.CreateChapter) + manage.POST("/epub", handler.EPUBNovel) // Update - manage.PUT("/:novel", token.AuthenticateOnly(), handler.UpdateNovel) - manage.PUT("/:novel/:chapter", token.AuthenticateOnly(), handler.UpdateChapter) + manage.PUT("/:novel", handler.UpdateNovel) + manage.PUT("/:novel/:chapter", handler.UpdateChapter) // Delete - manage.DELETE("/:novel", token.AuthenticateOnly(), handler.DeleteNovel) - manage.DELETE("/:novel/:chapter", token.AuthenticateOnly(), handler.DeleteChapter) + manage.DELETE("/:novel", handler.DeleteNovel) + manage.DELETE("/:novel/:chapter", handler.DeleteChapter) } user := r.Group("/user") From 7d0e5c66a3880b8da482df26191982963cbd4464 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 22:59:53 +0400 Subject: [PATCH 14/18] feat(server): Use GetDomains for CORS configuration --- api/internal/server/routes.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 93c6d9e..932b4d6 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -11,15 +11,13 @@ import ( ) func RegisteredRoutes(r *gin.Engine) { - domain_url := cmn.GetEnvVariable("DOMAIN") - if gin.Mode() == gin.DebugMode && domain_url == "" { - domain_url = "*" + domains := cmn.GetDomains("DOMAIN") + if gin.Mode() == gin.DebugMode && len(domains) == 0 { + domains[0] = "*" } r.Use(cors.New(cors.Config{ - AllowOrigins: []string{ - domain_url, - }, + AllowOrigins: domains, AllowMethods: []string{ "GET", "POST", From 7903ab61bbe321c1e8315623906e9b332f34435f Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 23:42:04 +0400 Subject: [PATCH 15/18] feat: Extract chapter processing to processChap function --- api/internal/service/novels.go | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/api/internal/service/novels.go b/api/internal/service/novels.go index 49a0dff..88436e2 100644 --- a/api/internal/service/novels.go +++ b/api/internal/service/novels.go @@ -113,6 +113,43 @@ func CreateNovelFromEPUB(data []byte, ctx context.Context) error { return nil } +func processChap(chapter pamphlet.Chapter, index int, author string) (*domain.Chapter, error) { + c_id, err := cmn.GenerateID("chapter") + if err != nil { + return nil, err + } + + rawContent, err := chapter.GetContent() + if err != nil { + return nil, err + } + + titleLower := strings.ToLower(chapter.Title) + contentLower := strings.ToLower(rawContent) + + if strings.HasPrefix(contentLower, titleLower) { + rawContent = rawContent[len(chapter.Title):] + rawContent = strings.TrimLeft(rawContent, " \t\n\r") + } + + content, err := cleanHtml(rawContent) + if err != nil { + return nil, err + } + + return &domain.Chapter{ + ID: c_id, + Title: chapter.Title, + Author: author, + Description: "", + CreatedAt: cmn.TimeStamp(""), + UpdatedAt: cmn.TimeStamp(""), + Content: content, + Index: index, + Deleted: false, + }, nil +} + func CreateNovel(novel domain.Novel, ctx context.Context) (error, string) { client, err := firestore_client.FirestoreClient() if err != nil { From 843e1b18b9b113201442f3b4c9242a96fed8c79a Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 23:42:05 +0400 Subject: [PATCH 16/18] refactor: Reorder chapters and use processChap in CreateNovelFromEPUB --- api/internal/service/novels.go | 40 +++++++++++++++------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/api/internal/service/novels.go b/api/internal/service/novels.go index 88436e2..f6bc3b9 100644 --- a/api/internal/service/novels.go +++ b/api/internal/service/novels.go @@ -73,36 +73,30 @@ func CreateNovelFromEPUB(data []byte, ctx context.Context) error { rawChapters := book.Chapters - c_id, err := cmn.GenerateID("chapter") - if err != nil { - return err + // Split chapters by priority + // Chpaters with chpater in title take priority + // Notes, Synopsys and everything else will be processes last + extraChapters := []pamphlet.Chapter{} + actualChapters := []pamphlet.Chapter{} + + for _, chapter := range rawChapters { + if strings.Contains(strings.ToLower(chapter.Title), "chapter") { + actualChapters = append(actualChapters, chapter) + } else { + extraChapters = append(extraChapters, chapter) + } } - chapters := make([]domain.Chapter, len(rawChapters)) - for i, chapter := range rawChapters { - rawContent, err := chapter.GetContent() - if err != nil { - return err - } + orderedChapters := append(actualChapters, extraChapters...) - content, err := cleanHtml(rawContent) + chapters := make([]domain.Chapter, len(orderedChapters)) + for i, chapter := range orderedChapters { + chap, err := processChap(chapter, i, book.Author) if err != nil { return err } - chapter := &domain.Chapter{ - ID: c_id, - Title: chapter.Title, - Author: book.Author, - Description: "", - CreatedAt: cmn.TimeStamp(""), - UpdatedAt: cmn.TimeStamp(""), - Content: content, - Index: i, - Deleted: false, - } - - chapters[i] = *chapter + chapters[i] = *chap } err = BatchUploadChapters(id, chapters, ctx) From 1935819f6b5842812e8de37e887e58bba4f5eaf0 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 23:48:35 +0400 Subject: [PATCH 17/18] feat: Prefix all API routes with `/api/` --- api/internal/server/routes.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 932b4d6..8b4ee51 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -55,7 +55,7 @@ func RegisteredRoutes(r *gin.Engine) { // Add mandatory token check r.Use(token.SetClaimsFromToken(), token.GlobalToken.AutoRefreshTokenMiddleware()) - client := r.Group("/") + client := r.Group("/api/") { client.GET("/all", handler.FindAllNovels) client.GET("/:novel", handler.FindNovel) @@ -64,7 +64,7 @@ func RegisteredRoutes(r *gin.Engine) { client.GET("/:novel/chapters", handler.GetPaginatedChapters) } - manage := r.Group("/manage") + manage := r.Group("/api/manage") { manage.Use(token.GlobalToken.LoadUser()) @@ -82,7 +82,7 @@ func RegisteredRoutes(r *gin.Engine) { manage.DELETE("/:novel/:chapter", handler.DeleteChapter) } - user := r.Group("/user") + user := r.Group("/api/user") { user.GET("/validate", handler.ValidateToken) user.POST("/login", handler.LoginUser) From 1a36cbae38fac14a7d64d4c8ab0299448d8a24f4 Mon Sep 17 00:00:00 2001 From: Aleksandre Date: Fri, 22 Aug 2025 23:51:35 +0400 Subject: [PATCH 18/18] Fix: Handle empty domains slice when initializing in debug mode --- api/internal/server/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go index 8b4ee51..aad9d1a 100644 --- a/api/internal/server/routes.go +++ b/api/internal/server/routes.go @@ -13,7 +13,7 @@ import ( func RegisteredRoutes(r *gin.Engine) { domains := cmn.GetDomains("DOMAIN") if gin.Mode() == gin.DebugMode && len(domains) == 0 { - domains[0] = "*" + domains = []string{"*"} } r.Use(cors.New(cors.Config{