diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 3c473d2..dde9392 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -111,6 +111,11 @@ jobs: POSTGRES_VERSION: 18 LOG_LEVEL: ${{ vars.LOG_LEVEL }} DATABASE_URL: ${{ secrets.DATABASE_URL }} + SERVICE_ADDRESS: ${{ vars.SERVICE_ADDRESS }} + RABBITMQ_URL: ${{ secrets.RABBITMQ_URL }} + JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} + RABBITMQ_DEFAULT_USER: ${{ secrets.RABBITMQ_USER }} + RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_PASSWORD }} steps: - name: Checkout code diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e85ddf9..e096beb 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -120,6 +120,8 @@ paths: description: Title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}': get: operationId: getUsersId @@ -223,6 +225,8 @@ paths: description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' '500': description: Unknown server error + security: + - XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -444,6 +448,8 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - XsrfAuthHeader: [] delete: operationId: deleteUserTitle summary: Delete a usertitle @@ -472,6 +478,8 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - XsrfAuthHeader: [] components: parameters: cursor: @@ -732,3 +740,11 @@ components: Review: type: object additionalProperties: true + securitySchemes: + XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). diff --git a/api/api.gen.go b/api/api.gen.go index c8fd9aa..459a3e4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,11 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +const ( + JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" + XsrfAuthHeaderScopes = "XsrfAuthHeader.Scopes" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -431,6 +436,8 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + // Parameter object where we will unmarshal all parameters from the context var params GetTitleParams @@ -501,6 +508,8 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -681,6 +690,8 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -747,6 +758,8 @@ func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { diff --git a/api/openapi.yaml b/api/openapi.yaml index 08a4d54..d84797f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -23,3 +23,5 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + securitySchemes: + $ref: "./securitySchemes/_index.yaml" \ No newline at end of file diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 235743f..f1b9c55 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,7 @@ get: summary: Get title description + security: + - JwtAuthCookies: [] operationId: getTitle parameters: - in: path diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b4ad884..1da2b81 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -34,6 +34,8 @@ patch: summary: Update a usertitle description: User updating title list of watched operationId: updateUserTitle + security: + - XsrfAuthHeader: [] parameters: - in: path name: user_id @@ -81,6 +83,8 @@ delete: summary: Delete a usertitle description: User deleting title from list of watched operationId: deleteUserTitle + security: + - XsrfAuthHeader: [] parameters: - in: path name: user_id diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index fe62e46..701df6b 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -33,7 +33,10 @@ patch: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser + security: + - XsrfAuthHeader: [] parameters: + # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true diff --git a/api/schemas/JWTAuth.yaml b/api/schemas/JWTAuth.yaml new file mode 100644 index 0000000..63c3baa --- /dev/null +++ b/api/schemas/JWTAuth.yaml @@ -0,0 +1,7 @@ +# type: apiKey +# in: cookie +# name: access_token +# scheme: bearer +# bearerFormat: JWT +# description: | +# JWT access token sent in `Cookie: access_token=...`. \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index d893ced..0cc0f9d 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -24,3 +24,5 @@ User: $ref: "./User.yaml" UserTitle: $ref: "./UserTitle.yaml" +# JwtAuth: +# $ref: "./JWTAuth.yaml" diff --git a/api/securitySchemes/_index.yaml b/api/securitySchemes/_index.yaml new file mode 100644 index 0000000..ecc0ff6 --- /dev/null +++ b/api/securitySchemes/_index.yaml @@ -0,0 +1,11 @@ +# accessToken: +# $ref: "./access_token.yaml" +# csrfToken: +# $ref: "./xsrf_token_cookie.yaml" +XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 7276545..b7cd839 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,32 +13,32 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) -// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. -type PostAuthSignInJSONBody struct { +// PostSignInJSONBody defines parameters for PostSignIn. +type PostSignInJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. -type PostAuthSignUpJSONBody struct { +// PostSignUpJSONBody defines parameters for PostSignUp. +type PostSignUpJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. -type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody +// PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. +type PostSignInJSONRequestBody PostSignInJSONBody -// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. -type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody +// PostSignUpJSONRequestBody defines body for PostSignUp for application/json ContentType. +type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(c *gin.Context) + // (POST /sign-in) + PostSignIn(c *gin.Context) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(c *gin.Context) + // (POST /sign-up) + PostSignUp(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -50,8 +50,8 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// PostAuthSignIn operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { +// PostSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -60,11 +60,11 @@ func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { } } - siw.Handler.PostAuthSignIn(c) + siw.Handler.PostSignIn(c) } -// PostAuthSignUp operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { +// PostSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostSignUp(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -73,7 +73,7 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { } } - siw.Handler.PostAuthSignUp(c) + siw.Handler.PostSignUp(c) } // GinServerOptions provides options for the Gin server. @@ -103,54 +103,54 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) - router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) + router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } -type PostAuthSignInRequestObject struct { - Body *PostAuthSignInJSONRequestBody +type PostSignInRequestObject struct { + Body *PostSignInJSONRequestBody } -type PostAuthSignInResponseObject interface { - VisitPostAuthSignInResponse(w http.ResponseWriter) error +type PostSignInResponseObject interface { + VisitPostSignInResponse(w http.ResponseWriter) error } -type PostAuthSignIn200JSONResponse struct { +type PostSignIn200JSONResponse struct { UserId int64 `json:"user_id"` UserName string `json:"user_name"` } -func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostAuthSignIn401JSONResponse struct { +type PostSignIn401JSONResponse struct { Error *string `json:"error,omitempty"` } -func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type PostAuthSignUpRequestObject struct { - Body *PostAuthSignUpJSONRequestBody +type PostSignUpRequestObject struct { + Body *PostSignUpJSONRequestBody } -type PostAuthSignUpResponseObject interface { - VisitPostAuthSignUpResponse(w http.ResponseWriter) error +type PostSignUpResponseObject interface { + VisitPostSignUpResponse(w http.ResponseWriter) error } -type PostAuthSignUp200JSONResponse struct { +type PostSignUp200JSONResponse struct { UserId int64 `json:"user_id"` } -func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { +func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) @@ -160,11 +160,11 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // (POST /sign-in) + PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // (POST /sign-up) + PostSignUp(ctx context.Context, request PostSignUpRequestObject) (PostSignUpResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -179,11 +179,11 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// PostAuthSignIn operation middleware -func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { - var request PostAuthSignInRequestObject +// PostSignIn operation middleware +func (sh *strictHandler) PostSignIn(ctx *gin.Context) { + var request PostSignInRequestObject - var body PostAuthSignInJSONRequestBody + var body PostSignInJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -192,10 +192,10 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + return sh.ssi.PostSignIn(ctx, request.(PostSignInRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignIn") + handler = middleware(handler, "PostSignIn") } response, err := handler(ctx, request) @@ -203,8 +203,8 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { - if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignInResponseObject); ok { + if err := validResponse.VisitPostSignInResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -212,11 +212,11 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { } } -// PostAuthSignUp operation middleware -func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { - var request PostAuthSignUpRequestObject +// PostSignUp operation middleware +func (sh *strictHandler) PostSignUp(ctx *gin.Context) { + var request PostSignUpRequestObject - var body PostAuthSignUpJSONRequestBody + var body PostSignUpJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -225,10 +225,10 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + return sh.ssi.PostSignUp(ctx, request.(PostSignUpRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignUp") + handler = middleware(handler, "PostSignUp") } response, err := handler(ctx, request) @@ -236,8 +236,8 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { - if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignUpResponseObject); ok { + if err := validResponse.VisitPostSignUpResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 239b03b..5f3ebd6 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -7,7 +7,7 @@ servers: - url: /auth paths: - /auth/sign-up: + /sign-up: post: summary: Sign up a new user tags: [Auth] @@ -38,7 +38,7 @@ paths: type: integer format: int64 - /auth/sign-in: + /sign-in: post: summary: Sign in a user and return JWT tags: [Auth] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..82116eb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,6 +47,9 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + RABBITMQ_URL: ${RABBITMQ_URL} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8080:8080" depends_on: @@ -62,6 +65,8 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8082:8082" depends_on: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 261826c..03df151 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "net/http" auth "nyanimedb/auth" @@ -15,15 +17,13 @@ import ( log "github.com/sirupsen/logrus" ) -var accessSecret = []byte("my_access_secret_key") -var refreshSecret = []byte("my_refresh_secret_key") - type Server struct { - db *sqlc.Queries + db *sqlc.Queries + JwtPrivateKey string } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, JwtPrivatekey string) Server { + return Server{db: db, JwtPrivateKey: JwtPrivatekey} } func parseInt64(s string) (int32, error) { @@ -47,15 +47,15 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func generateTokens(userID string) (accessToken string, refreshToken string, err error) { +func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(accessSecret) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,15 +63,22 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(refreshSecret) + refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) if err != nil { - return "", "", err + return "", "", "", err } - return accessToken, refreshToken, nil + csrfBytes := make([]byte, 32) + _, err = rand.Read(csrfBytes) + if err != nil { + return "", "", "", err + } + csrfToken = base64.RawURLEncoding.EncodeToString(csrfBytes) + + return accessToken, refreshToken, csrfToken, nil } -func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { +func (s Server) PostSignUp(ctx context.Context, req auth.PostSignUpRequestObject) (auth.PostSignUpResponseObject, error) { passhash, err := HashPassword(req.Body.Pass) if err != nil { log.Errorf("failed to hash password: %v", err) @@ -87,17 +94,17 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque // TODO: check err and retyrn 400/500 } - return auth.PostAuthSignUp200JSONResponse{ + return auth.PostSignUp200JSONResponse{ UserId: user_id, }, nil } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { +func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject) (auth.PostSignInResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") // TODO: change to 500 - return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + return auth.PostSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) @@ -113,12 +120,12 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } if !ok { err_msg := "invalid credentials" - return auth.PostAuthSignIn401JSONResponse{ + return auth.PostSignIn401JSONResponse{ Error: &err_msg, }, nil } - accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -126,10 +133,11 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) - result := auth.PostAuthSignIn200JSONResponse{ + result := auth.PostSignIn200JSONResponse{ UserId: user.ID, UserName: user.Nickname, } diff --git a/modules/auth/helpers.go b/modules/auth/helpers.go new file mode 100644 index 0000000..9c3ab36 --- /dev/null +++ b/modules/auth/helpers.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "reflect" +) + +func setField(obj interface{}, name string, value interface{}) error { + v := reflect.ValueOf(obj) + + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected pointer to a struct") + } + + v = v.Elem() + field := v.FieldByName(name) + + if !field.IsValid() { + return fmt.Errorf("no such field: %s", name) + } + if !field.CanSet() { + return fmt.Errorf("cannot set field: %s", name) + } + + val := reflect.ValueOf(value) + + if field.Type() != val.Type() { + return fmt.Errorf("provided value type (%s) doesn't match field type (%s)", val.Type(), field.Type()) + } + + field.Set(val) + return nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7554f42..7305b7d 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "reflect" "time" auth "nyanimedb/auth" @@ -13,12 +14,24 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - // TODO: env args + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } + + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } + r := gin.Default() pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) @@ -29,10 +42,11 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) + log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +61,41 @@ func main() { r.Run(":8082") } + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/auth/types.go b/modules/auth/types.go index 038b179..694843e 100644 --- a/modules/auth/types.go +++ b/modules/auth/types.go @@ -1,6 +1,9 @@ package main type Config struct { - JwtPrivateKey string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` } diff --git a/modules/backend/main.go b/modules/backend/main.go index 9f992a5..b833cf9 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -11,6 +11,7 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + middleware "nyanimedb/modules/backend/middlewares" "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" @@ -24,18 +25,18 @@ import ( var AppConfig Config func main() { - // if len(os.Args) != 2 { - // AppConfig.Mode = "env" - // } else { - // AppConfig.Mode = "argv" - // } + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } - // err := InitConfig() - // if err != nil { - // log.Fatalf("Failed to init config: %v\n", err) - // } + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } - pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), AppConfig.DdUrl) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) @@ -45,15 +46,12 @@ func main() { r := gin.Default() + r.Use(middleware.CSRFMiddleware()) + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + queries := sqlc.New(pool) - // === RabbitMQ setup === - rmqURL := os.Getenv("RABBITMQ_URL") - if rmqURL == "" { - rmqURL = "amqp://guest:guest@rabbitmq:5672/" - } - - rmqConn, err := amqp091.Dial(rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.RmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } @@ -63,12 +61,12 @@ func main() { rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) server := handlers.NewServer(queries, publisher, rpcClient) - // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, @@ -76,7 +74,7 @@ func main() { oapi.RegisterHandlers(r, oapi.NewStrictHandler( server, - // сюда можно добавить middlewares, если нужно + []oapi.StrictMiddlewareFunc{}, )) diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go new file mode 100644 index 0000000..73200e8 --- /dev/null +++ b/modules/backend/middlewares/access.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ctxKey — приватный тип для ключа контекста +type ctxKey struct{} + +// ginContextKey — уникальный ключ для хранения *gin.Context +var ginContextKey = &ctxKey{} + +// GinContextToContext сохраняет *gin.Context в context.Context запроса +func GinContextToContext(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), ginContextKey, c) + c.Request = c.Request.WithContext(ctx) +} + +// GinContextFromContext извлекает *gin.Context из context.Context +func GinContextFromContext(ctx context.Context) (*gin.Context, bool) { + ginCtx, ok := ctx.Value(ginContextKey).(*gin.Context) + return ginCtx, ok +} + +func JWTAuthMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. Получаем access_token из cookie + tokenStr, err := c.Cookie("access_token") + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "missing access_token cookie") + return + } + + // 2. Парсим токен с MapClaims + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method: " + t.Method.Alg()) + } + return []byte(secret), nil // ← конвертируем string → []byte + }) + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "invalid token: "+err.Error()) + return + } + + // 3. Проверяем валидность + if !token.Valid { + abortWithJSON(c, http.StatusUnauthorized, "token is invalid") + return + } + + // 4. Извлекаем user_id из claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + abortWithJSON(c, http.StatusUnauthorized, "invalid claims format") + return + } + + userID, ok := claims["user_id"].(string) + if !ok || userID == "" { + abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") + return + } + + // 5. Сохраняем в контексте + c.Set("user_id", userID) + + // 6. Для oapi-codegen — кладём gin.Context в request context + GinContextToContext(c) + + c.Next() + } +} + +// Вспомогательные функции (без изменений) +func UserIDFromGin(c *gin.Context) (string, bool) { + id, exists := c.Get("user_id") + if !exists { + return "", false + } + if s, ok := id.(string); ok { + return s, true + } + return "", false +} + +func UserIDFromContext(ctx context.Context) (string, error) { + ginCtx, ok := GinContextFromContext(ctx) + if !ok { + return "", errors.New("gin context not found") + } + userID, ok := UserIDFromGin(ginCtx) + if !ok { + return "", errors.New("user_id not found in context") + } + return userID, nil +} + +func abortWithJSON(c *gin.Context, code int, message string) { + c.AbortWithStatusJSON(code, gin.H{ + "error": "unauthorized", + "message": message, + }) +} diff --git a/modules/backend/middlewares/csrf.go b/modules/backend/middlewares/csrf.go new file mode 100644 index 0000000..41fad7b --- /dev/null +++ b/modules/backend/middlewares/csrf.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/gin-gonic/gin" +) + +// CSRFMiddleware для Gin +func CSRFMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Пропускаем безопасные методы + if !isStateChangingMethod(c.Request.Method) { + c.Next() + return + } + + // 1. Получаем токен из заголовка + headerToken := c.GetHeader("X-XSRF-TOKEN") + if headerToken == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing X-XSRF-TOKEN header", + }) + return + } + + // 2. Получаем токен из cookie + cookie, err := c.Cookie("xsrf_token") + if err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing xsrf_token cookie", + }) + return + } + + // 3. Безопасное сравнение + if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie)) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "CSRF token mismatch", + }) + return + } + + // 4. Опционально: сохраняем токен в контексте + c.Set("csrf_token", headerToken) + c.Next() + } +} + +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +// CSRFTokenFromGin извлекает токен из Gin context +func CSRFTokenFromGin(c *gin.Context) (string, bool) { + token, exists := c.Get("xsrf_token") + if !exists { + return "", false + } + if s, ok := token.(string); ok { + return s, true + } + return "", false +} diff --git a/modules/backend/types.go b/modules/backend/types.go index 20d3158..a069307 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -1,12 +1,10 @@ package main type Config struct { - Mode string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` -} - -type Item struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 40bb520..d2b5573 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" @@ -1868,6 +1869,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1890,7 +1903,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2524,7 +2536,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3260,6 +3271,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4068,6 +4088,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", + "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.6", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^8.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -4081,6 +4115,12 @@ "react": "^19.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4481,6 +4521,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/universal-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", + "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index e0b65ba..af07b41 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -15,6 +15,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 95b59e3..67336c1 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,6 +6,11 @@ import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; +import { OpenAPI } from "./api"; + +OpenAPI.WITH_CREDENTIALS = true +OpenAPI.CREDENTIALS = 'include' + const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 218b461..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,6 +20,7 @@ export class DefaultService { * @param cursor * @param sort * @param sortForward + * @param extSearch * @param word * @param status List of title statuses to filter * @param rating @@ -35,6 +36,7 @@ export class DefaultService { cursor?: string, sort?: TitleSort, sortForward: boolean = true, + extSearch: boolean = false, word?: string, status?: Array, rating?: number, @@ -57,6 +59,7 @@ export class DefaultService { 'cursor': cursor, 'sort': sort, 'sort_forward': sortForward, + 'ext_search': extSearch, 'word': word, 'status': status, 'rating': rating, diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index 94578d8..74a8fa7 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -12,19 +12,17 @@ export class AuthService { * @returns any Sign-up result * @throws ApiError */ - public static postAuthSignUp( + public static postSignUp( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - success?: boolean; - error?: string | null; - user_id?: string | null; + user_id: number; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-up', + url: '/sign-up', body: requestBody, mediaType: 'application/json', }); @@ -35,19 +33,18 @@ export class AuthService { * @returns any Sign-in result with JWT * @throws ApiError */ - public static postAuthSignIn( + public static postSignIn( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - error?: string | null; - user_id?: string | null; - user_name?: string | null; + user_id: number; + user_name: string; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-in', + url: '/sign-in', body: requestBody, mediaType: 'application/json', errors: { diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 0c9c741..cc9f80d 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; +// import { useCookies } from 'react-cookie'; + import { ClockIcon, CheckCircleIcon, @@ -17,6 +19,9 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { + // const [cookies] = useCookies(['xsrf_token']); + // const xsrfToken = cookies['xsrf_token'] || null; + const [currentStatus, setCurrentStatus] = useState(null); const [loading, setLoading] = useState(false); diff --git a/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx new file mode 100644 index 0000000..3cfef69 --- /dev/null +++ b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import type { TitleStatus, ReleaseSeason } from "../../api"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; + +export type TitlesFilter = { + extSearch: boolean; + status: TitleStatus | ""; + rating: number | ""; + releaseYear: number | ""; + releaseSeason: ReleaseSeason | ""; +}; + +type TitlesFilterPanelProps = { + filters: TitlesFilter; + setFilters: (filters: TitlesFilter) => void; +}; + +const STATUS_OPTIONS: (TitleStatus | "")[] = ["", "planned", "finished", "ongoing"]; +const SEASON_OPTIONS: (ReleaseSeason | "")[] = ["", "winter", "spring", "summer", "fall"]; +const RATING_OPTIONS = ["", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export function TitlesFilterPanel({ filters, setFilters }: TitlesFilterPanelProps) { + const [open, setOpen] = useState(false); + + const handleChange = (field: keyof TitlesFilter, value: any) => { + setFilters({ ...filters, [field]: value }); + }; + + return ( +
+
+ {/* Заголовок панели */} +
setOpen((prev) => !prev)} + > +

Filters

+ {open ? : } +
+ + {/* Контент панели */} + {open && ( +
+ {/* Extended Search */} +
+ handleChange("extSearch", e.target.checked)} + className="w-4 h-4" + /> + +
+ + {/* Status */} +
+ + +
+ + {/* Rating */} +
+ + +
+ + {/* Release Year */} +
+ + + handleChange("releaseYear", e.target.value ? Number(e.target.value) : "") + } + className="border rounded px-2 py-1" + placeholder="Any" + /> +
+ + {/* Release Season */} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/modules/frontend/src/main.tsx b/modules/frontend/src/main.tsx index bef5202..c225a33 100644 --- a/modules/frontend/src/main.tsx +++ b/modules/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { CookiesProvider } from 'react-cookie' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 89ee88c..928766e 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -17,23 +17,23 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + const res = await AuthService.postSignIn({ nickname, pass: password }); if (res.user_id && res.user_name) { // Сохраняем user_id и username в localStorage - localStorage.setItem("userId", res.user_id); + localStorage.setItem("userId", res.user_id.toString()); localStorage.setItem("username", res.user_name); navigate("/profile"); // редирект на профиль } else { - setError(res.error || "Login failed"); + setError("Login failed"); } } else { // SignUp оставляем без сохранения данных - const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + const res = await AuthService.postSignUp({ nickname, pass: password }); if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { - setError(res.error || "Sign up failed"); + setError("Sign up failed"); } } } catch (err: any) { diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index c9911b9..ed55d8d 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -8,6 +8,7 @@ import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal" import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; +import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; const PAGE_SIZE = 10; @@ -22,6 +23,14 @@ export default function TitlesPage() { const [sortForward, setSortForward] = useState(true); const [layout, setLayout] = useState<"square" | "horizontal">("square"); + const [filters, setFilters] = useState({ + extSearch: false, + status: "", + rating: "", + releaseYear: "", + releaseSeason: "", + }); + const fetchPage = async (cursorObj: CursorObj | null) => { const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; @@ -30,13 +39,14 @@ export default function TitlesPage() { cursorStr, sort, sortForward, + filters.extSearch, search.trim() || undefined, - undefined, - undefined, - undefined, - undefined, + filters.status ? [filters.status] : undefined, + filters.rating || undefined, + filters.releaseYear || undefined, + filters.releaseSeason || undefined, + PAGE_SIZE, PAGE_SIZE, - undefined, "all" ); @@ -73,7 +83,7 @@ export default function TitlesPage() { }; initLoad(); - }, [search, sort, sortForward]); + }, [search, sort, sortForward, filters]); const handleLoadMore = async () => { @@ -121,6 +131,7 @@ const handleLoadMore = async () => { setSortForward={setSortForward} /> + {loading &&
Loading...
}