diff --git a/.air.toml b/.air.toml index 95c39d4..6a48cc0 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", @@ -15,6 +15,7 @@ exclude_dir = [ "node_modules", ".vscode", ".idea", + ".github", ] exclude_file = [] exclude_regex = ["_test\\.go$", "\\.git", "\\.DS_Store"] 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 64% rename from api/internal/common/env.go rename to api/common/env.go index 773e746..9c56cd0 100644 --- a/api/internal/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 +} 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..20a7db8 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/service/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/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..162a1ef --- /dev/null +++ b/api/internal/domain/token.go @@ -0,0 +1,52 @@ +package domain + +import ( + "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 + 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) +} + +type LookupUser struct { + Cache TokenCache + CacheDuration time.Duration +} + +type InMemoryCache struct { + mu sync.RWMutex + items map[string]cacheItem +} + +type cacheItem struct { + value any + expiresAt time.Time +} diff --git a/api/internal/domain/user.go b/api/internal/domain/user.go index f9d26ea..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,11 +15,6 @@ type Credentials struct { Password string `json:"password"` } -type Claims struct { - Email string `json:"email"` - jwt.RegisteredClaims -} - type NewUser struct { Username string `json:"username"` Password string `json:"password"` 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/routes.go b/api/internal/interfaces/rest/routes.go deleted file mode 100644 index 782d6c9..0000000 --- a/api/internal/interfaces/rest/routes.go +++ /dev/null @@ -1,86 +0,0 @@ -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" - - "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 = "*" - } - - r.Use(cors.New(cors.Config{ - AllowOrigins: []string{ - domain, - }, - AllowMethods: []string{ - "GET", - "POST", - "DELETE", - "PUT", - }, - AllowHeaders: []string{ - "Origin", - "Content-Type", - "Content-Length", - "Accept-Encoding", - "X-CSRF-Token", - "X-Requested-With", - "Authorization", - "Accept", - "Access-Control-Allow-Origin", - "Access-Control-Allow-Credentials", - "Set-Cookie", - }, - ExposeHeaders: []string{ - "Content-Length", - "Content-Type", - "Access-Control-Allow-Origin", - "Access-Control-Allow-Credentials", - "Set-Cookie", - }, - AllowCredentials: true, - })) - - r.Use(firestore_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) - } - - 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) - - // Update - manage.PUT("/:novel", firestore_middleware.ValidateToken(), firestore_handlers.UpdateNovel) - manage.PUT("/:novel/:chapter", firestore_middleware.ValidateToken(), firestore_handlers.UpdateChapter) - - // Delete - manage.DELETE("/:novel", firestore_middleware.ValidateToken(), firestore_handlers.DeleteNovel) - manage.DELETE("/:novel/:chapter", firestore_middleware.ValidateToken(), firestore_handlers.DeleteChapter) - } - - user := r.Group("/user") - { - user.GET("/validate", firestore_middleware.ValidateToken(), firestore_handlers.ValidateToken) - user.POST("/login", firestore_handlers.LoginUser) - user.POST("/logout", firestore_handlers.LogoutUser) - user.POST("/register", firestore_handlers.RegisterUser) - } -} diff --git a/api/internal/interfaces/rest/handlers/chapters.go b/api/internal/server/handler/chapters.go similarity index 89% rename from api/internal/interfaces/rest/handlers/chapters.go rename to api/internal/server/handler/chapters.go index 70f7021..85d83c0 100644 --- a/api/internal/interfaces/rest/handlers/chapters.go +++ b/api/internal/server/handler/chapters.go @@ -1,9 +1,9 @@ -package firestore_handlers +package handler 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" + "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/interfaces/rest/handlers/novels.go b/api/internal/server/handler/novels.go similarity index 89% rename from api/internal/interfaces/rest/handlers/novels.go rename to api/internal/server/handler/novels.go index 48bc890..5532b77 100644 --- a/api/internal/interfaces/rest/handlers/novels.go +++ b/api/internal/server/handler/novels.go @@ -1,11 +1,11 @@ -package firestore_handlers +package handler 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" + "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/handler/token.go b/api/internal/server/handler/token.go new file mode 100644 index 0000000..b934a30 --- /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) (any, 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/interfaces/rest/handlers/users.go b/api/internal/server/handler/users.go similarity index 72% rename from api/internal/interfaces/rest/handlers/users.go rename to api/internal/server/handler/users.go index c036b76..89880a0 100644 --- a/api/internal/interfaces/rest/handlers/users.go +++ b/api/internal/server/handler/users.go @@ -1,9 +1,10 @@ -package firestore_handlers +package handler 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" + "Codex-Backend/api/internal/server/middleware/token" + "Codex-Backend/api/internal/service" "net/http" "github.com/gin-gonic/gin" @@ -29,7 +30,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 +68,7 @@ func LoginUser(c *gin.Context) { return } - token, 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(), @@ -80,15 +81,28 @@ func LoginUser(c *gin.Context) { return } - c.SetCookie("Authorization", token, 3600*24, "/", cmn.GetEnvVariable("DOMAIN"), true, true) + config := token.DefaultTokenConfig() + + 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(), + }) + 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(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", }) } @@ -101,7 +115,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(), @@ -114,36 +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, ok := c.Get("user") - if !ok { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ - "error": "User not found", - }) - return - } - - user, ok := result.(*domain.User) - if !ok { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ - "error": "Invalid user structure", - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "user": gin.H{ - "username": user.Username, - "email": user.Email, - "type": user.Type, - }, - "authenticated": true, - }) -} 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 96% rename from api/internal/interfaces/rest/middleware/is_admin.go rename to api/internal/server/middleware/is_admin.go index 0d27f2e..91d7ca0 100644 --- a/api/internal/interfaces/rest/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/interfaces/rest/middleware/rate_limiter.go b/api/internal/server/middleware/rate_limiter.go similarity index 93% rename from api/internal/interfaces/rest/middleware/rate_limiter.go rename to api/internal/server/middleware/rate_limiter.go index e87367f..69caab0 100644 --- a/api/internal/interfaces/rest/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..4971590 --- /dev/null +++ b/api/internal/server/middleware/token/cache_token.go @@ -0,0 +1,89 @@ +package token + +import ( + "Codex-Backend/api/internal/domain" + "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(5 * 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() + } +} + +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/generate.go b/api/internal/server/middleware/token/generate.go new file mode 100644 index 0000000..d43cf7c --- /dev/null +++ b/api/internal/server/middleware/token/generate.go @@ -0,0 +1,130 @@ +package token + +import ( + cmn "Codex-Backend/api/common" + "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, &cmn.Error{Err: errors.New("user ID cannot be empty")} + } + if email == "" { + return nil, &cmn.Error{Err: errors.New("email cannot be empty")} + } + if config.SigningKey == "" { + return nil, &cmn.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{}, &cmn.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{}, &cmn.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 "", &cmn.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 "", &cmn.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: cmn.GetEnvVariable("JWT_SIGN_KEY"), + AccessTTL: 30 * time.Minute, + RefreshTTL: 7 * 24 * time.Hour, + Issuer: cmn.GetEnvVariable("JWT_ISSUER"), + Audience: cmn.GetEnvVariable("JWT_AUDIENCE"), + } +} 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..e5af331 --- /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.LookupUser{ + 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.LookupUser, ctx context.Context) (string, error) { + config := DefaultTokenConfig() + + // Parse refresh token + 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"]) + } + 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/middleware/token/token.go b/api/internal/server/middleware/token/token.go new file mode 100644 index 0000000..293293c --- /dev/null +++ b/api/internal/server/middleware/token/token.go @@ -0,0 +1,157 @@ +package token + +import ( + cmn "Codex-Backend/api/common" + "Codex-Backend/api/internal/domain" + "Codex-Backend/api/internal/service" + "fmt" + "net/http" + "time" + + "cloud.google.com/go/firestore" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func SetClaimsFromToken() gin.HandlerFunc { + return func(c *gin.Context) { + 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) + } +} + +// 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 + } + + userClaims, ok := claims.(*domain.Claims) + if !ok { + c.Next() + return + } + + // Check cache first + var user *domain.User + cacheKey := fmt.Sprintf("user:%s", userClaims.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 := service.GetUserByID(userClaims.ID, c.Request.Context()) + 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("access_token"); err == nil && tokenString != "" { + return tokenString, nil + } + + // Try Authorization header as fallback + authHeader := c.GetHeader("access_token") + 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) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + key := cmn.GetEnvVariable("JWT_SIGN_KEY") + 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 +} + +type FirestoreUserService struct { + client *firestore.Client +} + +func (mf *IMTokenCache) LoadUser() gin.HandlerFunc { + return LookupUser(domain.LookupUser{ + Cache: mf.cache, + CacheDuration: 1 * time.Hour, + }) +} diff --git a/api/internal/server/routes.go b/api/internal/server/routes.go new file mode 100644 index 0000000..aad9d1a --- /dev/null +++ b/api/internal/server/routes.go @@ -0,0 +1,93 @@ +package server + +import ( + cmn "Codex-Backend/api/common" + "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" +) + +func RegisteredRoutes(r *gin.Engine) { + domains := cmn.GetDomains("DOMAIN") + if gin.Mode() == gin.DebugMode && len(domains) == 0 { + domains = []string{"*"} + } + + r.Use(cors.New(cors.Config{ + AllowOrigins: domains, + AllowMethods: []string{ + "GET", + "POST", + "DELETE", + "PUT", + }, + AllowHeaders: []string{ + "Origin", + "Content-Type", + "Content-Length", + "Accept-Encoding", + "X-CSRF-Token", + "X-Requested-With", + "Authorization", + "Accept", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Set-Cookie", + }, + ExposeHeaders: []string{ + "Content-Length", + "Content-Type", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Set-Cookie", + }, + AllowCredentials: true, + })) + + r.Use(middleware.RateLimiter()) + r.MaxMultipartMemory = 32 << 20 // 32 MB + + token.InitIMTokenCache() + + // Add mandatory token check + r.Use(token.SetClaimsFromToken(), token.GlobalToken.AutoRefreshTokenMiddleware()) + + client := r.Group("/api/") + { + 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("/api/manage") + { + manage.Use(token.GlobalToken.LoadUser()) + + // Create + manage.POST("/novel", handler.CreateNovel) + manage.POST("/:novel/chapter", handler.CreateChapter) + manage.POST("/epub", handler.EPUBNovel) + + // Update + manage.PUT("/:novel", handler.UpdateNovel) + manage.PUT("/:novel/:chapter", handler.UpdateChapter) + + // Delete + manage.DELETE("/:novel", handler.DeleteNovel) + manage.DELETE("/:novel/:chapter", handler.DeleteChapter) + } + + user := r.Group("/api/user") + { + user.GET("/validate", handler.ValidateToken) + user.POST("/login", handler.LoginUser) + user.POST("/logout", handler.LogoutUser) + user.POST("/register", handler.RegisterUser) + user.GET("/refresh", handler.RefreshToken) + } +} diff --git a/api/internal/interfaces/rest/server.go b/api/internal/server/server.go similarity index 94% rename from api/internal/interfaces/rest/server.go rename to api/internal/server/server.go index 1740f26..b0e6940 100644 --- a/api/internal/interfaces/rest/server.go +++ b/api/internal/server/server.go @@ -1,7 +1,7 @@ -package firestore_server +package 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/service/chapters.go similarity index 94% rename from api/internal/usecases/collections/chapters.go rename to api/internal/service/chapters.go index 57bf043..7a77d79 100644 --- a/api/internal/usecases/collections/chapters.go +++ b/api/internal/service/chapters.go @@ -1,10 +1,10 @@ -package firestore_services +package service 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/service/novels.go similarity index 72% rename from api/internal/usecases/collections/novels.go rename to api/internal/service/novels.go index 014e191..f6bc3b9 100644 --- a/api/internal/usecases/collections/novels.go +++ b/api/internal/service/novels.go @@ -1,10 +1,10 @@ -package firestore_services +package service 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" @@ -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) @@ -113,6 +107,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 { diff --git a/api/internal/usecases/collections/user.go b/api/internal/service/user.go similarity index 72% rename from api/internal/usecases/collections/user.go rename to api/internal/service/user.go index 9c79fd8..9c561f1 100644 --- a/api/internal/usecases/collections/user.go +++ b/api/internal/service/user.go @@ -1,20 +1,20 @@ -package firestore_services +package service 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/service/worker/args.go similarity index 71% rename from api/internal/usecases/worker/args.go rename to api/internal/service/worker/args.go index 4b23265..95cdcee 100644 --- a/api/internal/usecases/worker/args.go +++ b/api/internal/service/worker/args.go @@ -1,7 +1,7 @@ package worker import ( - firestore_services "Codex-Backend/api/internal/usecases/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 {