From bbe57e07d59ed06bb8cfdae815b570c99c3886ef Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 15 Nov 2025 02:53:25 +0300 Subject: [PATCH 01/60] feat: initial auth service support --- Dockerfiles/Dockerfile_auth | 6 + auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/auth/auth.gen.go | 329 ++++++++++++++++++++++++++++++ auth/oapi-auth-codegen.yaml | 6 + auth/openapi-auth.yaml | 112 ++++++++++ go.mod | 3 +- go.sum | 2 + modules/auth/handlers/handlers.go | 108 ++++++++++ modules/auth/main.go | 38 ++++ modules/auth/types.go | 6 + 10 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 Dockerfiles/Dockerfile_auth create mode 100644 auth/auth.gen.go create mode 100644 auth/auth/auth.gen.go create mode 100644 auth/oapi-auth-codegen.yaml create mode 100644 auth/openapi-auth.yaml create mode 100644 modules/auth/handlers/handlers.go create mode 100644 modules/auth/main.go create mode 100644 modules/auth/types.go diff --git a/Dockerfiles/Dockerfile_auth b/Dockerfiles/Dockerfile_auth new file mode 100644 index 0000000..5280e86 --- /dev/null +++ b/Dockerfiles/Dockerfile_auth @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/auth/auth /app +EXPOSE 8082 +ENTRYPOINT ["/app/auth"] \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go new file mode 100644 index 0000000..1f16575 --- /dev/null +++ b/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// 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) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + 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 { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + 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 { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/auth/auth.gen.go b/auth/auth/auth.gen.go new file mode 100644 index 0000000..12b6622 --- /dev/null +++ b/auth/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package oapi_auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi_auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// 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) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + 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 { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + 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 { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/oapi-auth-codegen.yaml b/auth/oapi-auth-codegen.yaml new file mode 100644 index 0000000..6792391 --- /dev/null +++ b/auth/oapi-auth-codegen.yaml @@ -0,0 +1,6 @@ +package: auth +generate: + strict-server: true + gin-server: true + models: true +output: auth/auth.gen.go \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml new file mode 100644 index 0000000..7ffc60e --- /dev/null +++ b/auth/openapi-auth.yaml @@ -0,0 +1,112 @@ +openapi: 3.1.0 +info: + title: Auth Service + version: 1.0.0 + +paths: + /auth/sign-up: + post: + summary: Sign up a new user + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-up result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + + /auth/sign-in: + post: + summary: Sign in a user and return JWT + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-in result with JWT + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + token: + type: string + description: JWT token to access protected endpoints + nullable: true + + /auth/verify-token: + post: + summary: Verify JWT validity + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + description: JWT token to validate + responses: + "200": + description: Token validation result + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + description: True if token is valid + user_id: + type: string + nullable: true + description: User ID extracted from token if valid + error: + type: string + nullable: true + description: Error message if token is invalid \ No newline at end of file diff --git a/go.mod b/go.mod index b7a66f2..4089c02 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.40.0 ) require ( @@ -38,6 +38,7 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/go.sum b/go.sum index 1af1a7c..d8c4265 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go new file mode 100644 index 0000000..ca72192 --- /dev/null +++ b/modules/auth/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "context" + "fmt" + auth "nyanimedb/auth" + sqlc "nyanimedb/sql" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var secretKey = []byte("my_secret_key") + +func generateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secretKey) +} + +var UserDb = make(map[string]string) //TEMP + +type Server struct { + db *sqlc.Queries +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + err := "" + success := true + t, _ := generateToken(req.Body.Nickname) + + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + Token: &t, + }, nil +} + +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { + err := "" + success := true + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignUp200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + }, nil +} + +func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { + valid := false + var userID *string + var errStr *string + + token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return secretKey, nil + }) + + if err != nil { + e := err.Error() + errStr = &e + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + if uid, ok := claims["user_id"].(string); ok { + valid = true + userID = &uid + } else { + e := "user_id not found in token" + errStr = &e + } + } else { + e := "invalid token claims" + errStr = &e + } + + return auth.PostAuthVerifyToken200JSONResponse{ + Valid: &valid, + UserId: userID, + Error: errStr, + }, nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go new file mode 100644 index 0000000..c001e8b --- /dev/null +++ b/modules/auth/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" + + auth "nyanimedb/auth" + handlers "nyanimedb/modules/auth/handlers" + sqlc "nyanimedb/sql" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +var AppConfig Config + +func main() { + r := gin.Default() + + var queries *sqlc.Queries = nil + + server := handlers.NewServer(queries) + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + auth.RegisterHandlers(r, auth.NewStrictHandler( + server, + []auth.StrictMiddlewareFunc{}, + )) + + r.Run(":8082") +} diff --git a/modules/auth/types.go b/modules/auth/types.go new file mode 100644 index 0000000..038b179 --- /dev/null +++ b/modules/auth/types.go @@ -0,0 +1,6 @@ +package main + +type Config struct { + JwtPrivateKey string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} From e64e770783883cd7f2fee60b83f9ad2dd41e254c Mon Sep 17 00:00:00 2001 From: nihonium Date: Sun, 23 Nov 2025 03:32:58 +0300 Subject: [PATCH 02/60] feat: use SetCookie for access and refresh tokens --- auth/auth.gen.go | 104 ++------------- auth/openapi-auth.yaml | 121 +++++++++++++----- modules/auth/handlers/handlers.go | 203 +++++++++++++++++++++--------- 3 files changed, 246 insertions(+), 182 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1f16575..adb2b06 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -25,21 +25,12 @@ type PostAuthSignUpJSONBody struct { Pass string `json:"pass"` } -// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. -type PostAuthVerifyTokenJSONBody struct { - // Token JWT token to validate - Token string `json:"token"` -} - // PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody // PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody -// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. -type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody - // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT @@ -48,9 +39,6 @@ type ServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(c *gin.Context) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -88,19 +76,6 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { siw.Handler.PostAuthSignUp(c) } -// PostAuthVerifyToken operation middleware -func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAuthVerifyToken(c) -} - // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -130,7 +105,6 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) - router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) } type PostAuthSignInRequestObject struct { @@ -144,10 +118,7 @@ type PostAuthSignInResponseObject interface { type PostAuthSignIn200JSONResponse struct { Error *string `json:"error"` Success *bool `json:"success,omitempty"` - - // Token JWT token to access protected endpoints - Token *string `json:"token"` - UserId *string `json:"user_id"` + UserId *string `json:"user_id"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -157,6 +128,17 @@ func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http return json.NewEncoder(w).Encode(response) } +type PostAuthSignIn401JSONResponse struct { + Error *string `json:"error,omitempty"` +} + +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(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 } @@ -178,32 +160,6 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http return json.NewEncoder(w).Encode(response) } -type PostAuthVerifyTokenRequestObject struct { - Body *PostAuthVerifyTokenJSONRequestBody -} - -type PostAuthVerifyTokenResponseObject interface { - VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error -} - -type PostAuthVerifyToken200JSONResponse struct { - // Error Error message if token is invalid - Error *string `json:"error"` - - // UserId User ID extracted from token if valid - UserId *string `json:"user_id"` - - // Valid True if token is valid - Valid *bool `json:"valid,omitempty"` -} - -func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT @@ -212,9 +168,6 @@ type StrictServerInterface interface { // Sign up a new user // (POST /auth/sign-up) PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) - // Verify JWT validity - // (POST /auth/verify-token) - PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -294,36 +247,3 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } - -// PostAuthVerifyToken operation middleware -func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { - var request PostAuthVerifyTokenRequestObject - - var body PostAuthVerifyTokenJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthVerifyToken") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { - if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 7ffc60e..b9ce76f 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.1.1 info: title: Auth Service version: 1.0.0 @@ -58,6 +58,14 @@ paths: responses: "200": description: Sign-in result with JWT + # headers: + # Set-Cookie: + # schema: + # type: array + # items: + # type: string + # explode: true + # style: simple content: application/json: schema: @@ -71,42 +79,89 @@ paths: user_id: type: string nullable: true - token: - type: string - description: JWT token to access protected endpoints - nullable: true - - /auth/verify-token: - post: - summary: Verify JWT validity - tags: [Auth] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - description: JWT token to validate - responses: - "200": - description: Token validation result + "401": + description: Access denied due to invalid credentials content: application/json: schema: type: object properties: - valid: - type: boolean - description: True if token is valid - user_id: - type: string - nullable: true - description: User ID extracted from token if valid error: type: string - nullable: true - description: Error message if token is invalid \ No newline at end of file + example: "Access denied" + # /auth/verify-token: + # post: + # summary: Verify JWT validity + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [token] + # properties: + # token: + # type: string + # description: JWT token to validate + # responses: + # "200": + # description: Token validation result + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if token is valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from token if valid + # error: + # type: string + # nullable: true + # description: Error message if token is invalid + # /auth/refresh-token: + # post: + # summary: Refresh JWT using a refresh token + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [refresh_token] + # properties: + # refresh_token: + # type: string + # description: JWT refresh token obtained from sign-in + # responses: + # "200": + # description: New access (and optionally refresh) token + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if refresh token was valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from refresh token + # access_token: + # type: string + # description: New access token + # nullable: true + # refresh_token: + # type: string + # description: New refresh token (optional) + # nullable: true + # error: + # type: string + # nullable: true + # description: Error message if refresh token is invalid diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index ca72192..9b9b0d3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,27 +3,21 @@ package handlers import ( "context" "fmt" + "log" + "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) -var secretKey = []byte("my_secret_key") +var accessSecret = []byte("my_access_secret_key") +var refreshSecret = []byte("my_refresh_secret_key") -func generateToken(userID string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(time.Hour * 24).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(secretKey) -} - -var UserDb = make(map[string]string) //TEMP +var UserDb = make(map[string]string) // TEMP: stores passwords type Server struct { db *sqlc.Queries @@ -38,19 +32,28 @@ func parseInt64(s string) (int32, error) { return int32(i), err } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - err := "" - success := true - t, _ := generateToken(req.Body.Nickname) +func generateTokens(userID string) (accessToken string, refreshToken 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) + if err != nil { + return "", "", err + } - UserDb[req.Body.Nickname] = req.Body.Pass + refreshClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + } + rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err = rt.SignedString(refreshSecret) + if err != nil { + return "", "", err + } - return auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, - Token: &t, - }, nil + return accessToken, refreshToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -65,44 +68,130 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque }, nil } -func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { - valid := false - var userID *string - var errStr *string +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + // ctx.SetCookie("122") + 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") + } - token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - return secretKey, nil - }) + err := "" + success := true - if err != nil { - e := err.Error() - errStr = &e - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, + pass, ok := UserDb[req.Body.Nickname] + if !ok || pass != req.Body.Pass { + e := "invalid credentials" + return auth.PostAuthSignIn401JSONResponse{ + Error: &e, }, nil } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - if uid, ok := claims["user_id"].(string); ok { - valid = true - userID = &uid - } else { - e := "user_id not found in token" - errStr = &e - } - } else { - e := "invalid token claims" - errStr = &e - } + accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) - return auth.PostAuthVerifyToken200JSONResponse{ - Valid: &valid, - UserId: userID, - Error: errStr, - }, nil + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + + // Return access token; refresh token can be returned in response or HttpOnly cookie + result := auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + } + return result, nil } + +// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return accessSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// valid = true +// userID = &uid +// } else { +// e := "user_id not found in token" +// errStr = &e +// } +// } else { +// e := "invalid token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return refreshSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// // Refresh token is valid, generate new tokens +// newAccessToken, newRefreshToken, _ := generateTokens(uid) +// valid = true +// userID = &uid +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: nil, +// Token: &newAccessToken, // return new access token +// // optionally return newRefreshToken as well +// }, nil +// } else { +// e := "user_id not found in refresh token" +// errStr = &e +// } +// } else { +// e := "invalid refresh token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } From a225d1fb6071622a5f293b40c2585ebc339b41a1 Mon Sep 17 00:00:00 2001 From: nihonium Date: Tue, 25 Nov 2025 04:13:52 +0300 Subject: [PATCH 03/60] feat: signup return username --- auth/auth.gen.go | 6 +++--- auth/openapi-auth.yaml | 14 ++++---------- modules/auth/handlers/handlers.go | 7 +++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index adb2b06..b24deb5 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,9 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + Error *string `json:"error"` + UserId *string `json:"user_id"` + UserName *string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index b9ce76f..b1b10ca 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -56,29 +56,23 @@ paths: type: string format: password responses: + # This one also sets two cookies: access_token and refresh_token "200": description: Sign-in result with JWT - # headers: - # Set-Cookie: - # schema: - # type: array - # items: - # type: string - # explode: true - # style: simple content: application/json: schema: type: object properties: - success: - type: boolean error: type: string nullable: true user_id: type: string nullable: true + user_name: + type: string + nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9b9b0d3..7f675aa 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,6 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } err := "" - success := true pass, ok := UserDb[req.Body.Nickname] if !ok || pass != req.Body.Pass { @@ -96,9 +95,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + Error: &err, + UserId: &req.Body.Nickname, + UserName: &req.Body.Nickname, } return result, nil } From 65b76d58c3936c84e9bedba00008046dc090e961 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 27 Nov 2025 03:19:53 +0300 Subject: [PATCH 04/60] fix: now post usertitle dont need title body --- api/_build/openapi.yaml | 24 +++++++++++++++++++++++- api/api.gen.go | 14 +++++++++++++- api/paths/users-id-titles.yaml | 25 ++++++++++++++++++++++++- modules/backend/handlers/users.go | 8 ++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 6b39558..d816a3a 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -326,7 +326,29 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserTitle' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time responses: '200': description: Title successfully added to user diff --git a/api/api.gen.go b/api/api.gen.go index f3e935c..5c49f12 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -226,11 +226,23 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// AddUserTitleJSONBody defines parameters for AddUserTitle. +type AddUserTitleJSONBody struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. -type AddUserTitleJSONRequestBody = UserTitle +type AddUserTitleJSONRequestBody AddUserTitleJSONBody // Getter for additional properties for Title. Returns the specified // element and whether it was found diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 23ea761..80b9916 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -108,7 +108,30 @@ post: content: application/json: schema: - $ref: '../schemas/UserTitle.yaml' + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + responses: '200': description: Title successfully added to user diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d800e7a..89b77e0 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -140,9 +140,9 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e } func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { - var sqlc_status sqlc.UsertitleStatusT + var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished if s == nil { - return nil, nil + return &sqlc_status, nil } switch *s { @@ -304,7 +304,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU tmp := fmt.Sprint(*t.Title.ReleaseYear) new_cursor.Param = &tmp case "rating": - tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает new_cursor.Param = &tmp } } @@ -369,7 +369,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque params := sqlc.InsertUserTitleParams{ UserID: request.UserId, - TitleID: request.Body.Title.Id, + TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, ReviewID: request.Body.ReviewId, From cb9fba6fbc5f2ec5e9b581bdea6cddde9508b071 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 27 Nov 2025 03:46:40 +0300 Subject: [PATCH 05/60] feat: patch usertitle described --- api/_build/openapi.yaml | 105 ++++++++++++------- api/api.gen.go | 162 ++---------------------------- api/paths/users-id-titles.yaml | 76 +++++++++----- modules/backend/handlers/users.go | 4 +- 4 files changed, 133 insertions(+), 214 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index d816a3a..e7482c1 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -328,13 +328,9 @@ paths: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -343,12 +339,6 @@ paths: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': description: Title successfully added to user @@ -356,32 +346,29 @@ paths: application/json: schema: type: object + required: + - user_id + - title_id + - status properties: - data: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -394,6 +381,53 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -629,4 +663,3 @@ components: ctime: type: string format: date-time - additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 5c49f12..cb5c1ae 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -151,10 +151,9 @@ type UserTitle struct { ReviewId *int64 `json:"review_id,omitempty"` // Status User's title status - Status UserTitleStatus `json:"status"` - Title *Title `json:"title,omitempty"` - UserId int64 `json:"user_id"` - AdditionalProperties map[string]interface{} `json:"-"` + Status UserTitleStatus `json:"status"` + Title *Title `json:"title,omitempty"` + UserId int64 `json:"user_id"` } // UserTitleStatus User's title status @@ -486,145 +485,6 @@ func (a Title) MarshalJSON() ([]byte, error) { return json.Marshal(object) } -// Getter for additional properties for UserTitle. Returns the specified -// element and whether it was found -func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for UserTitle -func (a *UserTitle) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a *UserTitle) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["ctime"]; found { - err = json.Unmarshal(raw, &a.Ctime) - if err != nil { - return fmt.Errorf("error reading 'ctime': %w", err) - } - delete(object, "ctime") - } - - if raw, found := object["rate"]; found { - err = json.Unmarshal(raw, &a.Rate) - if err != nil { - return fmt.Errorf("error reading 'rate': %w", err) - } - delete(object, "rate") - } - - if raw, found := object["review_id"]; found { - err = json.Unmarshal(raw, &a.ReviewId) - if err != nil { - return fmt.Errorf("error reading 'review_id': %w", err) - } - delete(object, "review_id") - } - - if raw, found := object["status"]; found { - err = json.Unmarshal(raw, &a.Status) - if err != nil { - return fmt.Errorf("error reading 'status': %w", err) - } - delete(object, "status") - } - - if raw, found := object["title"]; found { - err = json.Unmarshal(raw, &a.Title) - if err != nil { - return fmt.Errorf("error reading 'title': %w", err) - } - delete(object, "title") - } - - if raw, found := object["user_id"]; found { - err = json.Unmarshal(raw, &a.UserId) - if err != nil { - return fmt.Errorf("error reading 'user_id': %w", err) - } - delete(object, "user_id") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a UserTitle) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.Ctime != nil { - object["ctime"], err = json.Marshal(a.Ctime) - if err != nil { - return nil, fmt.Errorf("error marshaling 'ctime': %w", err) - } - } - - if a.Rate != nil { - object["rate"], err = json.Marshal(a.Rate) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rate': %w", err) - } - } - - if a.ReviewId != nil { - object["review_id"], err = json.Marshal(a.ReviewId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'review_id': %w", err) - } - } - - object["status"], err = json.Marshal(a.Status) - if err != nil { - return nil, fmt.Errorf("error marshaling 'status': %w", err) - } - - if a.Title != nil { - object["title"], err = json.Marshal(a.Title) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title': %w", err) - } - } - - object["user_id"], err = json.Marshal(a.UserId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'user_id': %w", err) - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -1313,16 +1173,14 @@ type AddUserTitleResponseObject interface { } type AddUserTitle200JSONResponse struct { - Data *struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` - // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - } `json:"data,omitempty"` + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` } func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 80b9916..1580cc1 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -110,13 +110,9 @@ post: schema: type: object required: - - user_id - title_id - status properties: - user_id: - type: integer - format: int64 title_id: type: integer format: int64 @@ -125,12 +121,6 @@ post: rate: type: integer format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time responses: '200': @@ -138,20 +128,8 @@ post: content: application/json: schema: - type: object - properties: - # success: - # type: boolean - # example: true - # error: - # type: string - # nullable: true - # example: null - data: - $ref: '../schemas/UserTitleMini.yaml' - # required: - # - success - # - error + $ref: '../schemas/UserTitleMini.yaml' + '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -162,5 +140,55 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + properties: + title_id: + type: integer + format: int64 + status: + $ref: ../schemas/enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 89b77e0..1881f36 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -360,7 +360,7 @@ func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestOb } func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { - + //TODO: add review if exists status, err := UserTitleStatus2Sqlc1(&request.Body.Status) if err != nil { log.Errorf("%v", err) @@ -404,5 +404,5 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque UserId: user_title.UserID, } - return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil + return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } From e0a68d7d0f9c4cd834ace7bc721a9d6dbe51aaba Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 27 Nov 2025 05:48:13 +0300 Subject: [PATCH 06/60] feat: delete usertitle described --- api/_build/openapi.yaml | 423 ++++++++++++++++++--------------- api/api.gen.go | 290 ++++++++++++++++++++-- api/openapi.yaml | 1 - api/paths/users-id-titles.yaml | 33 ++- api/schemas/UserTitleMini.yaml | 3 +- deploy/api_gen.ps1 | 4 + 6 files changed, 526 insertions(+), 228 deletions(-) create mode 100644 deploy/api_gen.ps1 diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e7482c1..403a45c 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,52 +11,52 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -69,10 +69,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -88,14 +88,14 @@ paths: get: summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -118,13 +118,13 @@ paths: get: summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -142,59 +142,59 @@ paths: '500': description: Unknown server error patch: + operationId: updateUser summary: Partially update a user account description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. - operationId: updateUser parameters: - name: user_id in: path + description: User ID (primary key) required: true schema: type: integer format: int64 - description: User ID (primary key) example: 123 requestBody: required: true content: application/json: schema: + description: Only provided fields are updated. Omitted fields remain unchanged. type: object properties: avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar type: integer format: int64 - nullable: true - description: ID of the user avatar (references `images.id`); set to `null` to remove avatar example: 42 + nullable: true mail: + description: User email (must be unique and valid) type: string format: email - pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' - description: User email (must be unique and valid) example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' nickname: - type: string - pattern: '^[a-zA-Z0-9_-]{3,16}$' description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 maxLength: 16 minLength: 3 - example: john_doe_43 + pattern: '^[a-zA-Z0-9_-]{3,16}$' disp_name: - type: string description: Display name - maxLength: 32 - example: John Smith - user_desc: type: string + example: John Smith + maxLength: 32 + user_desc: description: User description / bio - maxLength: 512 + type: string example: Just a curious developer. + maxLength: 512 additionalProperties: false - description: Only provided fields are updated. Omitted fields remain unchanged. responses: '200': description: User updated successfully. Returns updated user representation (excluding sensitive fields). @@ -222,64 +222,64 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: watch_status + style: form + - name: watch_status + in: query schema: type: array items: $ref: '#/components/schemas/UserTitleStatus' - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: my_rate + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -309,17 +309,17 @@ paths: '500': description: Unknown server error post: + operationId: addUserTitle summary: Add a title to a user description: 'User adding title to list af watched, status required' - operationId: addUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -327,9 +327,6 @@ paths: application/json: schema: type: object - required: - - title_id - - status properties: title_id: type: integer @@ -339,36 +336,16 @@ paths: rate: type: integer format: int32 + required: + - title_id + - status responses: '200': description: Title successfully added to user content: application/json: schema: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -382,17 +359,17 @@ paths: '500': description: Internal server error patch: + operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched - operationId: updateUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -400,8 +377,6 @@ paths: application/json: schema: type: object - required: - - title_id properties: title_id: type: integer @@ -411,13 +386,15 @@ paths: rate: type: integer format: int32 + required: + - title_id responses: '200': description: Title successfully updated content: application/json: schema: - $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -428,6 +405,30 @@ paths: description: User or Title not found '500': description: Internal server error + delete: + operationId: deleteUserTitle + summary: Delete a usertitle + description: User deleting title from list of watched + parameters: + - name: user_id + in: path + description: ID of the user to assign the title to + required: true + schema: + type: integer + format: int64 + example: 123 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -443,25 +444,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -469,65 +481,11 @@ components: type: integer format: int64 storage_type: - type: string - description: Image storage type - enum: - - s3 - - local + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -538,30 +496,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -571,6 +540,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -602,50 +580,68 @@ components: type: number format: double additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) + type: integer + format: int64 example: 1 image: $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object - required: - - user_id - - title_id - - status properties: user_id: type: integer @@ -663,3 +659,34 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index cb5c1ae..6af01d0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,12 +16,6 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) -// Defines values for ImageStorageType. -const ( - Local ImageStorageType = "local" - S3 ImageStorageType = "s3" -) - // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -30,6 +24,12 @@ const ( Winter ReleaseSeason = "winter" ) +// Defines values for StorageType. +const ( + Local StorageType = "local" + S3 StorageType = "s3" +) + // Defines values for TitleSort. const ( Id TitleSort = "id" @@ -65,15 +65,15 @@ type Image struct { ImagePath *string `json:"image_path,omitempty"` // StorageType Image storage type - StorageType *ImageStorageType `json:"storage_type,omitempty"` + StorageType *StorageType `json:"storage_type,omitempty"` } -// ImageStorageType Image storage type -type ImageStorageType string - // ReleaseSeason Title release season type ReleaseSeason string +// StorageType Image storage type +type StorageType string + // Studio defines model for Studio. type Studio struct { Description *string `json:"description,omitempty"` @@ -156,6 +156,18 @@ type UserTitle struct { UserId int64 `json:"user_id"` } +// UserTitleMini defines model for UserTitleMini. +type UserTitleMini struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` +} + // UserTitleStatus User's title status type UserTitleStatus string @@ -225,21 +237,30 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` + TitleId int64 `json:"title_id"` +} + // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status UserTitleStatus `json:"status"` TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` } // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody + // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody @@ -499,9 +520,15 @@ type ServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles) + DeleteUserTitle(c *gin.Context, userId int64) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Update a usertitle + // (PATCH /users/{user_id}/titles) + UpdateUserTitle(c *gin.Context, userId int64) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(c *gin.Context, userId int64) @@ -716,6 +743,30 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { siw.Handler.UpdateUser(c, userId) } +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId) +} + // GetUsersUserIdTitles operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { @@ -839,6 +890,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { siw.Handler.GetUsersUserIdTitles(c, userId, params) } +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId) +} + // AddUserTitle operation middleware func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { @@ -894,7 +969,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle) router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) } @@ -1110,6 +1187,54 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri return nil } +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdTitlesRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdTitlesParams @@ -1163,6 +1288,64 @@ func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesRespons return nil } +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type AddUserTitleRequestObject struct { UserId int64 `json:"user_id"` Body *AddUserTitleJSONRequestBody @@ -1172,16 +1355,7 @@ type AddUserTitleResponseObject interface { VisitAddUserTitleResponse(w http.ResponseWriter) error } -type AddUserTitle200JSONResponse struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` - - // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` -} +type AddUserTitle200JSONResponse UserTitleMini func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -1252,9 +1426,15 @@ type StrictServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) @@ -1390,6 +1570,33 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } } +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersUserIdTitles operation middleware func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { var request GetUsersUserIdTitlesRequestObject @@ -1418,6 +1625,41 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p } } +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // AddUserTitle operation middleware func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { var request AddUserTitleRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 7da26f8..23f2058 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -21,4 +21,3 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" - \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 1580cc1..18c805e 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -117,11 +117,10 @@ post: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 - responses: '200': description: Title successfully added to user @@ -169,7 +168,7 @@ patch: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 @@ -190,5 +189,33 @@ patch: description: Forbidden — user not allowed to update title '404': description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + + responses: + '200': + description: Title successfully deleted + # '400': + # description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index 9e45e95..e20bcbf 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -20,5 +20,4 @@ properties: format: int64 ctime: type: string - format: date-time -additionalProperties: false + format: date-time \ No newline at end of file diff --git a/deploy/api_gen.ps1 b/deploy/api_gen.ps1 new file mode 100644 index 0000000..c8966b7 --- /dev/null +++ b/deploy/api_gen.ps1 @@ -0,0 +1,4 @@ +cd ./api +openapi-format .\openapi.yaml --output .\_build\openapi.yaml --yaml +cd .. +oapi-codegen --config=api\oapi-codegen.yaml api\_build\openapi.yaml From 68294dd13c3bd02e57929260ecab044d3f63fd38 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 06:11:55 +0300 Subject: [PATCH 07/60] fix: oapi shitty generation --- api/_build/openapi.yaml | 402 +++++++++--------- api/paths/titles-id.yaml | 1 + api/paths/users-id-titles.yaml | 7 +- api/paths/users-id.yaml | 1 + api/schemas/Title.yaml | 1 - api/schemas/UserTitleMini.yaml | 1 - modules/frontend/src/api/index.ts | 2 + modules/frontend/src/api/models/Image.ts | 6 +- .../frontend/src/api/models/StorageType.ts | 8 + modules/frontend/src/api/models/Title.ts | 28 +- .../frontend/src/api/models/UserTitleMini.ts | 14 + .../src/api/services/DefaultService.ts | 51 ++- .../src/pages/TitlePage/TitlePage.module.css | 0 .../src/pages/TitlePage/TitlePage.tsx | 64 --- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- .../src/pages/UsersIdPage/UsersIdPage.tsx | 2 +- 16 files changed, 302 insertions(+), 288 deletions(-) create mode 100644 modules/frontend/src/api/models/StorageType.ts create mode 100644 modules/frontend/src/api/models/UserTitleMini.ts delete mode 100644 modules/frontend/src/pages/TitlePage/TitlePage.module.css diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e7482c1..720b686 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,52 +11,52 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -69,10 +69,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -86,16 +86,17 @@ paths: description: Unknown server error '/titles/{title_id}': get: + operationId: getTitle summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -116,15 +117,16 @@ paths: description: Unknown server error '/users/{user_id}': get: + operationId: getUsersId summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -142,59 +144,59 @@ paths: '500': description: Unknown server error patch: + operationId: updateUser summary: Partially update a user account description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. - operationId: updateUser parameters: - name: user_id in: path + description: User ID (primary key) required: true schema: type: integer format: int64 - description: User ID (primary key) example: 123 requestBody: required: true content: application/json: schema: + description: Only provided fields are updated. Omitted fields remain unchanged. type: object properties: avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar type: integer format: int64 - nullable: true - description: ID of the user avatar (references `images.id`); set to `null` to remove avatar example: 42 + nullable: true mail: + description: User email (must be unique and valid) type: string format: email - pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' - description: User email (must be unique and valid) example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' nickname: - type: string - pattern: '^[a-zA-Z0-9_-]{3,16}$' description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 maxLength: 16 minLength: 3 - example: john_doe_43 + pattern: '^[a-zA-Z0-9_-]{3,16}$' disp_name: - type: string description: Display name - maxLength: 32 - example: John Smith - user_desc: type: string + example: John Smith + maxLength: 32 + user_desc: description: User description / bio - maxLength: 512 + type: string example: Just a curious developer. + maxLength: 512 additionalProperties: false - description: Only provided fields are updated. Omitted fields remain unchanged. responses: '200': description: User updated successfully. Returns updated user representation (excluding sensitive fields). @@ -222,64 +224,64 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: type: array items: $ref: '#/components/schemas/TitleStatus' - description: List of title statuses to filter - style: form explode: false - - in: query - name: watch_status + style: form + - name: watch_status + in: query schema: type: array items: $ref: '#/components/schemas/UserTitleStatus' - style: form explode: false - - in: query - name: rating + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: my_rate + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -309,17 +311,17 @@ paths: '500': description: Unknown server error post: + operationId: addUserTitle summary: Add a title to a user description: 'User adding title to list af watched, status required' - operationId: addUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -327,9 +329,6 @@ paths: application/json: schema: type: object - required: - - title_id - - status properties: title_id: type: integer @@ -339,36 +338,16 @@ paths: rate: type: integer format: int32 + required: + - title_id + - status responses: '200': description: Title successfully added to user content: application/json: schema: - type: object - required: - - user_id - - title_id - - status - properties: - user_id: - type: integer - format: int64 - title_id: - type: integer - format: int64 - status: - $ref: '#/components/schemas/UserTitleStatus' - rate: - type: integer - format: int32 - review_id: - type: integer - format: int64 - ctime: - type: string - format: date-time - additionalProperties: false + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -382,17 +361,17 @@ paths: '500': description: Internal server error patch: + operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched - operationId: updateUserTitle parameters: - name: user_id in: path + description: ID of the user to assign the title to required: true schema: type: integer format: int64 - description: ID of the user to assign the title to example: 123 requestBody: required: true @@ -400,8 +379,6 @@ paths: application/json: schema: type: object - required: - - title_id properties: title_id: type: integer @@ -411,13 +388,15 @@ paths: rate: type: integer format: int32 + required: + - title_id responses: '200': description: Title successfully updated content: application/json: schema: - $ref: '#/paths/~1users~1%7Buser_id%7D~1titles/post/responses/200/content/application~1json/schema' + $ref: '#/components/schemas/UserTitleMini' '400': description: 'Invalid request body (missing fields, invalid types, etc.)' '401': @@ -443,25 +422,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -469,65 +459,11 @@ components: type: integer format: int64 storage_type: - type: string - description: Image storage type - enum: - - s3 - - local + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -538,30 +474,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -571,6 +518,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -601,51 +557,68 @@ components: additionalProperties: type: number format: double - additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) + type: integer + format: int64 example: 1 image: $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object - required: - - user_id - - title_id - - status properties: user_id: type: integer @@ -663,3 +636,34 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + UserTitleMini: + type: object + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + required: + - user_id + - title_id + - status + Review: + type: object + additionalProperties: true diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 01fa504..235743f 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,6 @@ get: summary: Get title description + operationId: getTitle parameters: - in: path name: title_id diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 1580cc1..4f11ab6 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -117,11 +117,10 @@ post: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 - responses: '200': description: Title successfully added to user @@ -129,7 +128,6 @@ post: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': @@ -169,7 +167,7 @@ patch: type: integer format: int64 status: - $ref: ../schemas/enums/UserTitleStatus.yaml + $ref: '../schemas/enums/UserTitleStatus.yaml' rate: type: integer format: int32 @@ -181,7 +179,6 @@ patch: application/json: schema: $ref: '../schemas/UserTitleMini.yaml' - '400': description: Invalid request body (missing fields, invalid types, etc.) '401': diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 06f4a19..fe62e46 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,5 +1,6 @@ get: summary: Get user info + operationId: getUsersId parameters: - in: path name: user_id diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 7497d1f..877ee24 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -60,4 +60,3 @@ properties: additionalProperties: type: number format: double -additionalProperties: true diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index 9e45e95..e1a5a74 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -21,4 +21,3 @@ properties: ctime: type: string format: date-time -additionalProperties: false diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 80ae491..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -12,6 +12,7 @@ export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { StorageType } from './models/StorageType'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; @@ -21,6 +22,7 @@ export type { TitleSort } from './models/TitleSort'; export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export type { UserTitleMini } from './models/UserTitleMini'; export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index a94de74..887bf2f 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -2,12 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { StorageType } from './StorageType'; export type Image = { id?: number; - /** - * Image storage type - */ - storage_type?: 's3' | 'local'; + storage_type?: StorageType; image_path?: string; }; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts index 4da7aa3..9ffdeb6 100644 --- a/modules/frontend/src/api/models/Title.ts +++ b/modules/frontend/src/api/models/Title.ts @@ -2,4 +2,30 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record; +import type { Image } from './Image'; +import type { ReleaseSeason } from './ReleaseSeason'; +import type { Studio } from './Studio'; +import type { Tags } from './Tags'; +import type { TitleStatus } from './TitleStatus'; +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: Record>; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: Record; +}; + diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 874971e..5070fae 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -9,6 +9,7 @@ import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleMini } from '../models/UserTitleMini'; import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; @@ -78,7 +79,7 @@ export class DefaultService { * @returns Title Title description * @throws ApiError */ - public static getTitles1( + public static getTitle( titleId: number, fields: string = 'all', ): CancelablePromise { @@ -105,7 +106,7 @@ export class DefaultService { * @returns User User info * @throws ApiError */ - public static getUsers( + public static getUsersId( userId: string, fields: string = 'all', ): CancelablePromise<User> { @@ -248,22 +249,17 @@ export class DefaultService { * User adding title to list af watched, status required * @param userId ID of the user to assign the title to * @param requestBody - * @returns any Title successfully added to user + * @returns UserTitleMini Title successfully added to user * @throws ApiError */ public static addUserTitle( userId: number, - requestBody: UserTitle, - ): CancelablePromise<{ - data?: { - user_id: number; + requestBody: { title_id: number; status: UserTitleStatus; rate?: number; - review_id?: number; - ctime?: string; - }; - }> { + }, + ): CancelablePromise<UserTitleMini> { return __request(OpenAPI, { method: 'POST', url: '/users/{user_id}/titles', @@ -282,4 +278,37 @@ export class DefaultService { }, }); } + /** + * Update a usertitle + * User updating title list of watched + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + requestBody: { + title_id: number; + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 7fe9de7..e69de29 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,64 +0,0 @@ -// import React, { useEffect, useState } from "react"; -// import { useParams } from "react-router-dom"; -// import { DefaultService } from "../../api/services/DefaultService"; -// import type { User } from "../../api/models/User"; -// import styles from "./UserPage.module.css"; - -// const UserPage: React.FC = () => { -// const { id } = useParams<{ id: string }>(); -// const [user, setUser] = useState<User | null>(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState<string | null>(null); - -// useEffect(() => { -// if (!id) return; - -// const getTitleInfo = async () => { -// try { -// const userInfo = await DefaultService.getTitle(id, "all"); -// setUser(userInfo); -// } catch (err) { -// console.error(err); -// setError("Failed to fetch user info."); -// } finally { -// setLoading(false); -// } -// }; -// getTitleInfo(); -// }, [id]); - -// if (loading) return <div className={styles.loader}>Loading...</div>; -// if (error) return <div className={styles.error}>{error}</div>; -// if (!user) return <div className={styles.error}>User not found.</div>; - -// return ( -// <div className={styles.container}> -// <div className={styles.card}> -// <div className={styles.avatar}> -// {user.avatar_id ? ( -// <img -// src={`/images/${user.avatar_id}.png`} -// alt="User Avatar" -// className={styles.avatarImg} -// /> -// ) : ( -// <div className={styles.avatarPlaceholder}> -// {user.disp_name?.[0] || "U"} -// </div> -// )} -// </div> - -// <div className={styles.info}> -// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> -// <p className={styles.nickname}>@{user.nickname}</p> -// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} -// <p className={styles.created}> -// Joined: {new Date(user.creation_date).toLocaleDateString()} -// </p> -// </div> -// </div> -// </div> -// ); -// }; - -// export default UserPage; diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 2e39e6b..eafdf6b 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -15,7 +15,7 @@ const UserPage: React.FC = () => { const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id + const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx index 342f22c..729da20 100644 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx @@ -41,7 +41,7 @@ export default function UsersIdPage({ userId }: UsersIdPageProps) { if (!id) return; setLoadingUser(true); try { - const result = await DefaultService.getUsers(id, "all"); + const result = await DefaultService.getUsersId(id, "all"); setUser(result); setErrorUser(null); } catch (err: any) { From 4c643d80bb35cff875e4e5d2fad9eec2fc4e0bcc Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:29:36 +0300 Subject: [PATCH 08/60] feat: added title page --- modules/frontend/src/App.tsx | 3 + modules/frontend/src/api/core/OpenAPI.ts | 2 +- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../src/pages/TitlePage/TitlePage.tsx | 140 ++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 3ecfa2d..e2c909f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; @@ -24,7 +25,9 @@ const App: React.FC = () => { /> <Route path="/users/:id" element={<UsersIdPage />} /> + <Route path="/titles" element={<TitlesPage />} /> + <Route path="/titles/:id" element={<TitlePage />} /> </Routes> </Router> ); diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 2d0edf8..79aa305 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/auth', + BASE: 'http://10.1.0.65:8081/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index e69de29..5ea0e3d 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title, UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, +]; + +export default function TitlePage() { + const params = useParams(); + const titleId = Number(params.id); + + const [title, setTitle] = useState<Title | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); + const [updatingStatus, setUpdatingStatus] = useState(false); + + useEffect(() => { + const fetchTitle = async () => { + setLoading(true); + try { + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); + setError(null); + } catch (err: any) { + console.error(err); + setError(err?.message || "Failed to fetch title"); + } finally { + setLoading(false); + } + }; + fetchTitle(); + }, [titleId]); + + const handleStatusClick = async (status: UserTitleStatus) => { + if (updatingStatus || userStatus === status) return; + + const userId = Number(localStorage.getItem("userId")); + if (!userId) { + alert("You must be logged in to set status."); + return; + } + + setUpdatingStatus(true); + try { + await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setUserStatus(status); + } catch (err: any) { + console.error(err); + alert(err?.message || "Failed to set status"); + } finally { + setUpdatingStatus(false); + } + }; + + const getTagsString = () => + title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); + + if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; + if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; + if (!title) return null; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> + <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> + {/* Постер */} + <div className="flex flex-col items-center"> + <img + src={title.poster?.image_path || "/default-poster.png"} + alt={title.title_names?.en?.[0] || "Title poster"} + className="w-48 h-72 object-cover rounded-lg mb-4" + /> + + {/* Статус кнопки с иконками */} + <div className="flex gap-2 mt-2 flex-wrap justify-center"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={updatingStatus} + className={`p-2 rounded-lg transition flex items-center justify-center ${ + userStatus === btn.status + ? "bg-blue-600 text-white" + : "bg-gray-200 text-gray-700 hover:bg-gray-300" + }`} + title={btn.label} + > + {btn.icon} + </button> + ))} + </div> + </div> + + {/* Информация о тайтле */} + <div className="flex-1 flex flex-col"> + <h1 className="text-3xl font-bold mb-2"> + {title.title_names?.en?.[0] || "Untitled"} + </h1> + {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( + <p className="text-gray-700 mb-1"> + Rating: {title.rating} ({title.rating_count} votes) + </p> + )} + {title.release_year && ( + <p className="text-gray-700 mb-1"> + Released: {title.release_year} {title.release_season || ""} + </p> + )} + {title.episodes_aired !== undefined && ( + <p className="text-gray-700 mb-1"> + Episodes: {title.episodes_aired}/{title.episodes_all} + </p> + )} + {title.tags && title.tags.length > 0 && ( + <p className="text-gray-700 mb-1"> + Tags: {getTagsString()} + </p> + )} + </div> + </div> + </div> + ); +} From e98d2c65094efa8a5bb52b70102905287b1c5e1e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:35:43 +0300 Subject: [PATCH 09/60] cicd: build auth using actions --- .forgejo/workflows/build-and-deploy.yml | 25 +++++++++++++++++++++++-- go.mod | 3 --- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index e7d0a83..0338440 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -20,9 +20,9 @@ jobs: go-version: '^1.25' check-latest: false cache-dependency-path: | - modules/backend/go.sum + go.sum - - name: Build Go app + - name: Build backend run: | cd modules/backend go mod tidy @@ -35,6 +35,19 @@ jobs: name: nyanimedb-backend.tar.gz path: modules/backend/nyanimedb-backend.tar.gz + - name: Build auth + run: | + cd modules/auth + go mod tidy + go build -o auth . + tar -czvf nyanimedb-auth.tar.gz auth + + - name: Upload built auth to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-auth.tar.gz + path: modules/auth/nyanimedb-auth.tar.gz + # Build frontend - uses: actions/setup-node@v5 with: @@ -76,6 +89,14 @@ jobs: push: true tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest + - name: Build and push auth image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_auth + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + - name: Build and push frontend image uses: docker/build-push-action@v6 with: diff --git a/go.mod b/go.mod index 72df275..bf73121 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,7 @@ require ( github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 -<<<<<<< HEAD -======= github.com/sirupsen/logrus v1.9.3 ->>>>>>> front ) require ( From 79e8ece9482b5f19f00d3aee9227668f81053e57 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 10/60] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From f2589e05e8ebef3bfbe6342310757dca3dafe983 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 07:06:18 +0300 Subject: [PATCH 11/60] fix: now 409 on try to add existing usertitle --- modules/backend/handlers/common.go | 7 ++--- modules/backend/handlers/titles.go | 4 +-- modules/backend/handlers/users.go | 50 +++++++++++++++++------------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 2cf2283..f820db6 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,7 +1,6 @@ package handlers import ( - "context" "encoding/json" "fmt" oapi "nyanimedb/api" @@ -17,11 +16,11 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } -func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { if s == nil { return nil, nil } - var t oapi.ImageStorageType + var t oapi.StorageType switch *s { case sqlc.StorageTypeTLocal: t = oapi.Local @@ -33,7 +32,7 @@ func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { return &t, nil } -func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { +func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { oapi_title := oapi.Title{ EpisodesAired: title.EpisodesAired, diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index c67177f..03553fd 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -144,7 +144,7 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl return oapi.GetTitlesTitleId500Response{}, nil } - oapi_title, err = s.mapTitle(ctx, sqlc_title) + oapi_title, err = s.mapTitle(sqlc_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitlesTitleId500Response{}, nil @@ -238,7 +238,7 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // _title.TitleStorageType = string(s) // } - t, err := s.mapTitle(ctx, _title) + t, err := s.mapTitle(_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 1881f36..7af705e 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" @@ -9,24 +10,12 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" log "github.com/sirupsen/logrus" ) -// type Server struct { -// db *sqlc.Queries -// } - -// func NewServer(db *sqlc.Queries) Server { -// return Server{db: db} -// } - -// func parseInt64(s string) (int32, error) { -// i, err := strconv.ParseInt(s, 10, 64) -// return int32(i), err -// } - func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { i := oapi.Image{ Id: u.AvatarID, @@ -202,7 +191,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o // StudioImagePath: title.StudioImagePath, } - oapi_title, err := s.mapTitle(ctx, _title) + oapi_title, err := s.mapTitle(_title) if err != nil { return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) } @@ -368,19 +357,26 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque } params := sqlc.InsertUserTitleParams{ - UserID: request.UserId, - TitleID: request.Body.TitleId, - Status: *status, - Rate: request.Body.Rate, - ReviewID: request.Body.ReviewId, + UserID: request.UserId, + TitleID: request.Body.TitleId, + Status: *status, + Rate: request.Body.Rate, } user_title, err := s.db.InsertUserTitle(ctx, params) if err != nil { - log.Errorf("%v", err) - return oapi.AddUserTitle500Response{}, nil + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + // fmt.Println(pgErr.Message) // => syntax error at end of input + // fmt.Println(pgErr.Code) // => 42601 + if pgErr.Code == "23505" { //duplicate key value + return oapi.AddUserTitle409Response{}, nil + } + } else { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } } - oapi_status, err := sql2usertitlestatus(user_title.Status) if err != nil { log.Errorf("%v", err) @@ -406,3 +402,13 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil } + +// DeleteUserTitle implements oapi.StrictServerInterface. +func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { + panic("unimplemented") +} + +// UpdateUserTitle implements oapi.StrictServerInterface. +func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) { + panic("unimplemented") +} From 658d666fec71c165de4492111bfa9d29f42cef18 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 07:08:06 +0300 Subject: [PATCH 12/60] feat: query for update usertitle --- modules/backend/queries.sql | 28 ++++++++--------------- sql/migrations/000001_init.up.sql | 2 +- sql/queries.sql.go | 38 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 0146b25..ef6e26d 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -461,21 +461,13 @@ VALUES ( ) RETURNING user_id, title_id, status, rate, review_id, ctime; --- -- name: UpdateUserTitle :one --- UPDATE usertitles --- SET --- status = COALESCE(sqlc.narg('status'), status), --- rate = COALESCE(sqlc.narg('rate'), rate), --- review_id = COALESCE(sqlc.narg('review_id'), review_id) --- WHERE user_id = $1 AND title_id = $2 --- RETURNING *; - --- -- name: DeleteUserTitle :exec --- DELETE FROM usertitles --- WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); - --- -- name: ListTags :many --- SELECT tag_id, tag_names --- FROM tags --- ORDER BY tag_id --- LIMIT $1 OFFSET $2; \ No newline at end of file +-- name: UpdateUserTitle :one +-- Fails with sql.ErrNoRows if (user_id, title_id) not found +UPDATE usertitles +SET + status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), + rate = COALESCE(sqlc.narg('rate')::int, rate) +WHERE + user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') +RETURNING *; \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index f8781de..3499fe2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -179,6 +179,6 @@ END; $$ LANGUAGE plpgsql; CREATE TRIGGER set_ctime_on_update -AFTER UPDATE ON usertitles +BEFORE UPDATE ON usertitles FOR EACH ROW EXECUTE FUNCTION set_ctime(); \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a46da86..89b16c9 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -925,3 +925,41 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateU ) return i, err } + +const updateUserTitle = `-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE($1::usertitle_status_t, status), + rate = COALESCE($2::int, rate) +WHERE + user_id = $3 + AND title_id = $4 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type UpdateUserTitleParams struct { + Status NullUsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +// Fails with sql.ErrNoRows if (user_id, title_id) not found +func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, updateUserTitle, + arg.Status, + arg.Rate, + arg.UserID, + arg.TitleID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} From 451df61127709b3c9a557e87745e8d5827b6be3a Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 08:00:29 +0300 Subject: [PATCH 13/60] feat: delete usertitle implemented --- api/_build/openapi.yaml | 8 +- api/api.gen.go | 392 +++++++---------------------- api/paths/users-id-titles.yaml | 8 +- modules/backend/handlers/titles.go | 10 +- modules/backend/handlers/users.go | 30 ++- modules/backend/queries.sql | 88 +------ sql/queries.sql.go | 116 ++------- 7 files changed, 160 insertions(+), 492 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e2c7409..2ee6cdc 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -419,7 +419,12 @@ paths: schema: type: integer format: int64 - example: 123 + - name: title_id + in: query + required: true + schema: + type: integer + format: int64 responses: '200': description: Title successfully deleted @@ -581,7 +586,6 @@ components: additionalProperties: type: number format: double - additionalProperties: true required: - id - title_names diff --git a/api/api.gen.go b/api/api.gen.go index 6af01d0..6208050 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -112,8 +112,7 @@ type Title struct { TitleNames map[string][]string `json:"title_names"` // TitleStatus Title status - TitleStatus *TitleStatus `json:"title_status,omitempty"` - AdditionalProperties map[string]interface{} `json:"-"` + TitleStatus *TitleStatus `json:"title_status,omitempty"` } // TitleSort Title sort order @@ -191,13 +190,13 @@ type GetTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. -type GetTitlesTitleIdParams struct { +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetUsersUserIdParams defines parameters for GetUsersUserId. -type GetUsersUserIdParams struct { +// GetUsersIdParams defines parameters for GetUsersId. +type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } @@ -219,6 +218,11 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// DeleteUserTitleParams defines parameters for DeleteUserTitle. +type DeleteUserTitleParams struct { + TitleId int64 `form:"title_id" json:"title_id"` +} + // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -264,248 +268,6 @@ type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody -// Getter for additional properties for Title. Returns the specified -// element and whether it was found -func (a Title) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for Title -func (a *Title) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for Title to handle AdditionalProperties -func (a *Title) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["episodes_aired"]; found { - err = json.Unmarshal(raw, &a.EpisodesAired) - if err != nil { - return fmt.Errorf("error reading 'episodes_aired': %w", err) - } - delete(object, "episodes_aired") - } - - if raw, found := object["episodes_all"]; found { - err = json.Unmarshal(raw, &a.EpisodesAll) - if err != nil { - return fmt.Errorf("error reading 'episodes_all': %w", err) - } - delete(object, "episodes_all") - } - - if raw, found := object["episodes_len"]; found { - err = json.Unmarshal(raw, &a.EpisodesLen) - if err != nil { - return fmt.Errorf("error reading 'episodes_len': %w", err) - } - delete(object, "episodes_len") - } - - if raw, found := object["id"]; found { - err = json.Unmarshal(raw, &a.Id) - if err != nil { - return fmt.Errorf("error reading 'id': %w", err) - } - delete(object, "id") - } - - if raw, found := object["poster"]; found { - err = json.Unmarshal(raw, &a.Poster) - if err != nil { - return fmt.Errorf("error reading 'poster': %w", err) - } - delete(object, "poster") - } - - if raw, found := object["rating"]; found { - err = json.Unmarshal(raw, &a.Rating) - if err != nil { - return fmt.Errorf("error reading 'rating': %w", err) - } - delete(object, "rating") - } - - if raw, found := object["rating_count"]; found { - err = json.Unmarshal(raw, &a.RatingCount) - if err != nil { - return fmt.Errorf("error reading 'rating_count': %w", err) - } - delete(object, "rating_count") - } - - if raw, found := object["release_season"]; found { - err = json.Unmarshal(raw, &a.ReleaseSeason) - if err != nil { - return fmt.Errorf("error reading 'release_season': %w", err) - } - delete(object, "release_season") - } - - if raw, found := object["release_year"]; found { - err = json.Unmarshal(raw, &a.ReleaseYear) - if err != nil { - return fmt.Errorf("error reading 'release_year': %w", err) - } - delete(object, "release_year") - } - - if raw, found := object["studio"]; found { - err = json.Unmarshal(raw, &a.Studio) - if err != nil { - return fmt.Errorf("error reading 'studio': %w", err) - } - delete(object, "studio") - } - - if raw, found := object["tags"]; found { - err = json.Unmarshal(raw, &a.Tags) - if err != nil { - return fmt.Errorf("error reading 'tags': %w", err) - } - delete(object, "tags") - } - - if raw, found := object["title_names"]; found { - err = json.Unmarshal(raw, &a.TitleNames) - if err != nil { - return fmt.Errorf("error reading 'title_names': %w", err) - } - delete(object, "title_names") - } - - if raw, found := object["title_status"]; found { - err = json.Unmarshal(raw, &a.TitleStatus) - if err != nil { - return fmt.Errorf("error reading 'title_status': %w", err) - } - delete(object, "title_status") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for Title to handle AdditionalProperties -func (a Title) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.EpisodesAired != nil { - object["episodes_aired"], err = json.Marshal(a.EpisodesAired) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) - } - } - - if a.EpisodesAll != nil { - object["episodes_all"], err = json.Marshal(a.EpisodesAll) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) - } - } - - if a.EpisodesLen != nil { - object["episodes_len"], err = json.Marshal(a.EpisodesLen) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) - } - } - - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) - } - - if a.Poster != nil { - object["poster"], err = json.Marshal(a.Poster) - if err != nil { - return nil, fmt.Errorf("error marshaling 'poster': %w", err) - } - } - - if a.Rating != nil { - object["rating"], err = json.Marshal(a.Rating) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating': %w", err) - } - } - - if a.RatingCount != nil { - object["rating_count"], err = json.Marshal(a.RatingCount) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) - } - } - - if a.ReleaseSeason != nil { - object["release_season"], err = json.Marshal(a.ReleaseSeason) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_season': %w", err) - } - } - - if a.ReleaseYear != nil { - object["release_year"], err = json.Marshal(a.ReleaseYear) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_year': %w", err) - } - } - - if a.Studio != nil { - object["studio"], err = json.Marshal(a.Studio) - if err != nil { - return nil, fmt.Errorf("error marshaling 'studio': %w", err) - } - } - - object["tags"], err = json.Marshal(a.Tags) - if err != nil { - return nil, fmt.Errorf("error marshaling 'tags': %w", err) - } - - object["title_names"], err = json.Marshal(a.TitleNames) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_names': %w", err) - } - - if a.TitleStatus != nil { - object["title_status"], err = json.Marshal(a.TitleStatus) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_status': %w", err) - } - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -513,16 +275,16 @@ type ServerInterface interface { GetTitles(c *gin.Context, params GetTitlesParams) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) + GetTitle(c *gin.Context, titleId int64, params GetTitleParams) // Get user info // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) // Delete a usertitle // (DELETE /users/{user_id}/titles) - DeleteUserTitle(c *gin.Context, userId int64) + DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams) // Get user titles // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) @@ -649,8 +411,8 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { siw.Handler.GetTitles(c, params) } -// GetTitlesTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { var err error @@ -664,7 +426,7 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetTitlesTitleIdParams + var params GetTitleParams // ------------- Optional query parameter "fields" ------------- @@ -681,11 +443,11 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } } - siw.Handler.GetTitlesTitleId(c, titleId, params) + siw.Handler.GetTitle(c, titleId, params) } -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { +// GetUsersId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { var err error @@ -699,7 +461,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams + var params GetUsersIdParams // ------------- Optional query parameter "fields" ------------- @@ -716,7 +478,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } } - siw.Handler.GetUsersUserId(c, userId, params) + siw.Handler.GetUsersId(c, userId, params) } // UpdateUser operation middleware @@ -757,6 +519,24 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } + // Parameter object where we will unmarshal all parameters from the context + var params DeleteUserTitleParams + + // ------------- Required query parameter "title_id" ------------- + + if paramValue := c.Query("title_id"); paramValue != "" { + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument title_id is required, but not found"), http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), ¶ms.TitleId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -764,7 +544,7 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { } } - siw.Handler.DeleteUserTitle(c, userId) + siw.Handler.DeleteUserTitle(c, userId, params) } // GetUsersUserIdTitles operation middleware @@ -966,8 +746,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/titles", wrapper.GetTitles) - router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) @@ -1021,94 +801,94 @@ func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWrite return nil } -type GetTitlesTitleIdRequestObject struct { +type GetTitleRequestObject struct { TitleId int64 `json:"title_id"` - Params GetTitlesTitleIdParams + Params GetTitleParams } -type GetTitlesTitleIdResponseObject interface { - VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error } -type GetTitlesTitleId200JSONResponse Title +type GetTitle200JSONResponse Title -func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitlesTitleId204Response struct { +type GetTitle204Response struct { } -func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitlesTitleId400Response struct { +type GetTitle400Response struct { } -func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitlesTitleId404Response struct { +type GetTitle404Response struct { } -func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle404Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetTitlesTitleId500Response struct { +type GetTitle500Response struct { } -func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetUsersUserIdRequestObject struct { +type GetUsersIdRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdParams + Params GetUsersIdParams } -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error +type GetUsersIdResponseObject interface { + VisitGetUsersIdResponse(w http.ResponseWriter) error } -type GetUsersUserId200JSONResponse User +type GetUsersId200JSONResponse User -func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId200JSONResponse) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserId400Response struct { +type GetUsersId400Response struct { } -func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId400Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserId404Response struct { +type GetUsersId404Response struct { } -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId404Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserId500Response struct { +type GetUsersId500Response struct { } -func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1189,6 +969,7 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri type DeleteUserTitleRequestObject struct { UserId int64 `json:"user_id"` + Params DeleteUserTitleParams } type DeleteUserTitleResponseObject interface { @@ -1419,10 +1200,10 @@ type StrictServerInterface interface { GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) // Get user info // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) @@ -1479,18 +1260,18 @@ func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { } } -// GetTitlesTitleId operation middleware -func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { - var request GetTitlesTitleIdRequestObject +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTitleParams) { + var request GetTitleRequestObject request.TitleId = titleId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitlesTitleId") + handler = middleware(handler, "GetTitle") } response, err := handler(ctx, request) @@ -1498,8 +1279,8 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1507,18 +1288,18 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param } } -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject +// GetUsersId operation middleware +func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { + var request GetUsersIdRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + return sh.ssi.GetUsersId(ctx, request.(GetUsersIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserId") + handler = middleware(handler, "GetUsersId") } response, err := handler(ctx, request) @@ -1526,8 +1307,8 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUsersIdResponseObject); ok { + if err := validResponse.VisitGetUsersIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1571,10 +1352,11 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } // DeleteUserTitle operation middleware -func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64) { +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) { var request DeleteUserTitleRequestObject request.UserId = userId + request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 2cff448..0cb7092 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -202,7 +202,13 @@ delete: type: integer format: int64 description: ID of the user to assign the title to - example: 123 + - in: query + name: title_id + required: true + schema: + type: integer + format: int64 + responses: '200': diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 03553fd..77af7e4 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -132,25 +132,25 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro // return &oapi_studio, nil // } -func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { +func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { var oapi_title oapi.Title sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetTitlesTitleId204Response{}, nil + return oapi.GetTitle204Response{}, nil } log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } oapi_title, err = s.mapTitle(sqlc_title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } - return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil + return oapi.GetTitle200JSONResponse(oapi_title), nil } func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 7af705e..48f80d8 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -16,6 +16,10 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + pgErrDuplicateKey = "23505" +) + func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { i := oapi.Image{ Id: u.AvatarID, @@ -37,24 +41,24 @@ func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { }, nil } -func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { +func (s Server) GetUsersId(ctx context.Context, req oapi.GetUsersIdRequestObject) (oapi.GetUsersIdResponseObject, error) { userID, err := parseInt64(req.UserId) if err != nil { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } return nil, err } user, err := mapUser(_user) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserId500Response{}, err + return oapi.GetUsersId500Response{}, err } - return oapi.GetUsersUserId200JSONResponse(user), nil + return oapi.GetUsersId200JSONResponse(user), nil } func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { @@ -369,7 +373,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque if errors.As(err, &pgErr) { // fmt.Println(pgErr.Message) // => syntax error at end of input // fmt.Println(pgErr.Code) // => 42601 - if pgErr.Code == "23505" { //duplicate key value + if pgErr.Code == pgErrDuplicateKey { //duplicate key value return oapi.AddUserTitle409Response{}, nil } } else { @@ -405,7 +409,19 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque // DeleteUserTitle implements oapi.StrictServerInterface. func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { - panic("unimplemented") + params := sqlc.DeleteUserTitleParams{ + UserID: request.UserId, + TitleID: request.Params.TitleId, + } + _, err := s.db.DeleteUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.DeleteUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.DeleteUserTitle500Response{}, nil + } + return oapi.DeleteUserTitle200Response{}, nil } // UpdateUserTitle implements oapi.StrictServerInterface. diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ef6e26d..5ac2c5c 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -57,17 +57,6 @@ VALUES ( sqlc.arg('tag_names')::jsonb) RETURNING id, tag_names; --- -- name: ListUsers :many --- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date --- FROM users --- ORDER BY user_id --- LIMIT $1 OFFSET $2; - --- -- name: CreateUser :one --- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) --- VALUES ($1, $2, $3, $4, $5, $6, $7) --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - -- name: UpdateUser :one UPDATE users SET @@ -78,10 +67,6 @@ SET WHERE id = sqlc.arg('user_id') RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; --- -- name: DeleteUser :exec --- DELETE FROM users --- WHERE user_id = $1; - -- name: GetTitleByID :one -- sqlc.struct: TitlesFull SELECT @@ -378,78 +363,11 @@ ORDER BY LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- -- name: ListTitles :many --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- ORDER BY title_id --- LIMIT $1 OFFSET $2; - --- -- name: UpdateTitle :one --- UPDATE titles --- SET --- title_names = COALESCE(sqlc.narg('title_names'), title_names), --- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), --- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), --- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), --- title_status = COALESCE(sqlc.narg('title_status'), title_status), --- release_year = COALESCE(sqlc.narg('release_year'), release_year), --- release_season = COALESCE(sqlc.narg('release_season'), release_season), --- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), --- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), --- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) --- WHERE title_id = sqlc.arg('title_id') --- RETURNING *; - -- name: GetReviewByID :one SELECT * FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; --- -- name: CreateReview :one --- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; - --- -- name: UpdateReview :one --- UPDATE reviews --- SET --- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), --- review_text = COALESCE(sqlc.narg('review_text'), review_text) --- WHERE review_id = sqlc.arg('review_id') --- RETURNING *; - --- -- name: DeleteReview :exec --- DELETE FROM reviews --- WHERE review_id = $1; - --- -- name: ListReviewsByTitle :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE title_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: ListReviewsByUser :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE user_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: GetUserTitle :one --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 AND title_id = $2; - --- -- name: ListUserTitles :many --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 --- ORDER BY usertitle_id --- LIMIT $2 OFFSET $3; - -- name: InsertUserTitle :one INSERT INTO usertitles (user_id, title_id, status, rate, review_id) VALUES ( @@ -470,4 +388,10 @@ SET WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') +RETURNING *; + +-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') RETURNING *; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 89b16c9..24f77b4 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,32 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const deleteUserTitle = `-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = $1 + AND title_id = $2 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type DeleteUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, deleteUserTitle, arg.UserID, arg.TitleID) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -44,40 +70,12 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one - - SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` // 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { row := q.db.QueryRow(ctx, getReviewByID, reviewID) var i Review @@ -111,7 +109,6 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er } const getTitleByID = `-- name: GetTitleByID :one - SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, @@ -162,9 +159,6 @@ type GetTitleByIDRow struct { StudioImagePath *string `json:"studio_image_path"` } -// -- name: DeleteUser :exec -// DELETE FROM users -// WHERE user_id = $1; // sqlc.struct: TitlesFull func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { row := q.db.QueryRow(ctx, getTitleByID, titleID) @@ -330,13 +324,6 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const insertUserTitle = `-- name: InsertUserTitle :one - - - - - - - INSERT INTO usertitles (user_id, title_id, status, rate, review_id) VALUES ( $1::bigint, @@ -356,46 +343,6 @@ type InsertUserTitleParams struct { ReviewID *int64 `json:"review_id"` } -// -- name: CreateReview :one -// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) -// VALUES ($1, $2, $3, $4, $5) -// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; -// -- name: UpdateReview :one -// UPDATE reviews -// SET -// -// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), -// review_text = COALESCE(sqlc.narg('review_text'), review_text) -// -// WHERE review_id = sqlc.arg('review_id') -// RETURNING *; -// -- name: DeleteReview :exec -// DELETE FROM reviews -// WHERE review_id = $1; -// -// -- name: ListReviewsByTitle :many -// -// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -// FROM reviews -// WHERE title_id = $1 -// ORDER BY creation_date DESC -// LIMIT $2 OFFSET $3; -// -- name: ListReviewsByUser :many -// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -// FROM reviews -// WHERE user_id = $1 -// ORDER BY creation_date DESC -// LIMIT $2 OFFSET $3; -// -- name: GetUserTitle :one -// SELECT usertitle_id, user_id, title_id, status, rate, review_id -// FROM usertitles -// WHERE user_id = $1 AND title_id = $2; -// -- name: ListUserTitles :many -// SELECT usertitle_id, user_id, title_id, status, rate, review_id -// FROM usertitles -// WHERE user_id = $1 -// ORDER BY usertitle_id -// LIMIT $2 OFFSET $3; func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { row := q.db.QueryRow(ctx, insertUserTitle, arg.UserID, @@ -866,8 +813,6 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } const updateUser = `-- name: UpdateUser :one - - UPDATE users SET avatar_id = COALESCE($1, avatar_id), @@ -896,15 +841,6 @@ type UpdateUserRow struct { Mail *string `json:"mail"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { row := q.db.QueryRow(ctx, updateUser, arg.AvatarID, From a25a912ead2e5ff2b81edd67191afe3ec3ce4b13 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 27 Nov 2025 08:16:12 +0300 Subject: [PATCH 14/60] feat: Update UserTitle implemented --- modules/backend/handlers/users.go | 49 ++++++++++++++++++++++++------- sql/queries.sql.go | 8 ++--- sql/sqlc.yaml | 5 ++++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 48f80d8..563a244 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -386,16 +386,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque log.Errorf("%v", err) return oapi.AddUserTitle500Response{}, nil } - oapi_usertitle := struct { - Ctime *time.Time `json:"ctime,omitempty"` - Rate *int32 `json:"rate,omitempty"` - ReviewId *int64 `json:"review_id,omitempty"` - - // Status User's title status - Status oapi.UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - }{ + oapi_usertitle := oapi.UserTitleMini{ Ctime: &user_title.Ctime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, @@ -426,5 +417,41 @@ func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitl // UpdateUserTitle implements oapi.StrictServerInterface. func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) { - panic("unimplemented") + + status, err := UserTitleStatus2Sqlc1(request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle400Response{}, nil + } + params := sqlc.UpdateUserTitleParams{ + Status: status, + Rate: request.Body.Rate, + UserID: request.UserId, + TitleID: request.Body.TitleId, + } + + user_title, err := s.db.UpdateUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.UpdateUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 24f77b4..9338717 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -874,10 +874,10 @@ RETURNING user_id, title_id, status, rate, review_id, ctime ` type UpdateUserTitleParams struct { - Status NullUsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` } // Fails with sql.ErrNoRows if (user_id, title_id) not found diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index de67bcf..8f8626a 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,11 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "usertitle_status_t" + nullable: true + go_type: + type: "UsertitleStatusT" + pointer: true - db_type: "storage_type_t" nullable: true go_type: From 6cbf0afb33e73939ec54b5785964d90168ffca33 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 15/60] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a46da86..371337f 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -268,6 +287,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index de67bcf..a4d8875 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 3528ea7d344b471fec6923d9fa2ba3ec8b7c7fa9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 16/60] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From 40e0b14f2a909f6dfe779c779446c22cf7558176 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 17/60] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..3318a14 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -55,6 +55,25 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -262,6 +281,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 8f8626a..904abaf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 9338c6504051462f362f0ccf26085f2d108b7c05 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:44:41 +0300 Subject: [PATCH 18/60] chore: updated sqlc generated code --- sql/queries.sql.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 3318a14..c1186b5 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const deleteUserTitle = `-- name: DeleteUserTitle :one DELETE FROM usertitles WHERE user_id = $1 @@ -55,25 +74,6 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } -const createNewUser = `-- name: CreateNewUser :one -INSERT -INTO users (passhash, nickname) -VALUES ($1, $2) -RETURNING id -` - -type CreateNewUserParams struct { - Passhash string `json:"passhash"` - Nickname string `json:"nickname"` -} - -func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { - row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) - var id int64 - err := row.Scan(&id) - return id, err -} - const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 98178731b9f6a03a9cd1a31b8005be70fc14492e Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 09:51:49 +0300 Subject: [PATCH 19/60] refact: UsersIdPage -> UserPage --- modules/frontend/src/App.tsx | 16 +- .../src/pages/UserPage/UserPage.module.css | 103 -------- .../frontend/src/pages/UserPage/UserPage.tsx | 240 +++++++++++++----- .../src/pages/UsersIdPage/UsersIdPage.tsx | 183 ------------- 4 files changed, 187 insertions(+), 355 deletions(-) delete mode 100644 modules/frontend/src/pages/UserPage/UserPage.module.css delete mode 100644 modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index e2c909f..95b59e3 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,13 +1,12 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; +import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - // Получаем username из localStorage const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); @@ -15,17 +14,20 @@ const App: React.FC = () => { <Router> <Header username={username} /> <Routes> + {/* auth */} <Route path="/login" element={<LoginPage />} /> <Route path="/signup" element={<LoginPage />} /> - - {/* /profile рендерит UsersIdPage с id из localStorage */} + {/*<Route path="/signup" element={<LoginPage />} />*/} + + {/* users */} + {/*<Route path="/users" element={<UsersPage />} />*/} + <Route path="/users/:id" element={<UserPage />} /> <Route path="/profile" - element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />} + element={userId ? <UserPage userId={userId} /> : <LoginPage />} /> - <Route path="/users/:id" element={<UsersIdPage />} /> - + {/* titles */} <Route path="/titles" element={<TitlesPage />} /> <Route path="/titles/:id" element={<TitlePage />} /> </Routes> diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css deleted file mode 100644 index 7f350c8..0000000 --- a/modules/frontend/src/pages/UserPage/UserPage.module.css +++ /dev/null @@ -1,103 +0,0 @@ -body, -html { - width: 100%; - margin: 0; - background-color: #777; - color: #fff; -} - -html, -body, -#root { - height: 100%; -} - -.header { - width: 100vw; - padding: 30px 40px; - background: #f7f7f7; - display: flex; - align-items: center; - gap: 25px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - border-bottom: 1px solid #e5e5e5; - color: #000000; -} - -.avatarWrapper { - width: 120px; - height: 120px; - min-width: 120px; - border-radius: 50%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #ddd; -} - -.avatarImg { - width: 100%; - height: 100%; - object-fit: cover; -} - -.avatarPlaceholder { - width: 100%; - height: 100%; - border-radius: 50%; - background: #ccc; - font-size: 42px; - font-weight: bold; - color: #555; - display: flex; - align-items: center; - justify-content: center; -} - -.userInfo { - display: flex; - flex-direction: column; -} - -.name { - font-size: 32px; - font-weight: 700; - margin: 0; -} - -.nickname { - font-size: 18px; - color: #666; - margin-top: 6px; -} - -.container { - max-width: 100vw; - width: 100%; - position: absolute; - top: 0%; - /* margin: 25px auto; */ - /* padding: 0 20px; */ -} - -.content { - margin-top: 20px; -} - -.desc { - font-size: 18px; - margin-bottom: 10px; -} - -.created { - font-size: 16px; - color: #888; -} - -.loader, -.error { - text-align: center; - margin-top: 40px; - font-size: 18px; -} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index eafdf6b..5fbd6b8 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,67 +1,183 @@ -import React, { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; // <-- import +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { User } from "../../api/models/User"; -import styles from "./UserPage.module.css"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; -const UserPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); // <-- get user id from URL - const [user, setUser] = useState<User | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); +const PAGE_SIZE = 10; - useEffect(() => { - if (!id) return; - - const getUserInfo = async () => { - try { - const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id - setUser(userInfo); - } catch (err) { - console.error(err); - setError("Failed to fetch user info."); - } finally { - setLoading(false); - } - }; - getUserInfo(); - }, [id]); - - if (loading) return <div className={styles.loader}>Loading...</div>; - if (error) return <div className={styles.error}>{error}</div>; - if (!user) return <div className={styles.error}>User not found.</div>; - - return ( - <div className={styles.container}> - <div className={styles.header}> - <div className={styles.avatarWrapper}> - {user.image?.image_path ? ( - <img - src={`/images/${user.image.image_path}.png`} - alt="User Avatar" - className={styles.avatarImg} - /> - ) : ( - <div className={styles.avatarPlaceholder}> - {user.disp_name?.[0] || "U"} - </div> - )} - </div> - - <div className={styles.userInfo}> - <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> - <p className={styles.nickname}>@{user.nickname}</p> - {/* <p className={styles.created}> - Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> */} - </div> - - <div className={styles.content}> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - </div> - </div> - </div> - ); +type UserPageProps = { + userId?: string; }; -export default UserPage; +export default function UserPage({ userId }: UserPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsersId(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUsersTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> + } + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx deleted file mode 100644 index 729da20..0000000 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// pages/UserPage/UserPage.tsx -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; -import { SearchBar } from "../../components/SearchBar/SearchBar"; -import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; -import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; -import { ListView } from "../../components/ListView/ListView"; -import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; -import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; -import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; - -const PAGE_SIZE = 10; - -type UsersIdPageProps = { - userId?: string; -}; - -export default function UsersIdPage({ userId }: UsersIdPageProps) { - const params = useParams(); - const id = userId || params?.id; - - const [user, setUser] = useState<User | null>(null); - const [loadingUser, setLoadingUser] = useState(true); - const [errorUser, setErrorUser] = useState<string | null>(null); - - // Для списка тайтлов - const [titles, setTitles] = useState<UserTitle[]>([]); - const [nextPage, setNextPage] = useState<UserTitle[]>([]); - const [cursor, setCursor] = useState<CursorObj | null>(null); - const [loadingTitles, setLoadingTitles] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [sort, setSort] = useState<TitleSort>("id"); - const [sortForward, setSortForward] = useState(true); - const [layout, setLayout] = useState<"square" | "horizontal">("square"); - - // --- Получение данных пользователя --- - useEffect(() => { - const fetchUser = async () => { - if (!id) return; - setLoadingUser(true); - try { - const result = await DefaultService.getUsersId(id, "all"); - setUser(result); - setErrorUser(null); - } catch (err: any) { - console.error(err); - setErrorUser(err?.message || "Failed to fetch user data"); - } finally { - setLoadingUser(false); - } - }; - fetchUser(); - }, [id]); - - // --- Получение списка тайтлов пользователя --- - const fetchPage = async (cursorObj: CursorObj | null) => { - if (!id) return { items: [], nextCursor: null }; - const cursorStr = cursorObj - ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") - : ""; - - try { - const result = await DefaultService.getUsersTitles( - id, - cursorStr, - sort, - sortForward, - search.trim() || undefined, - undefined, // status фильтр, можно добавить - undefined, // watchStatus - undefined, // rating - undefined, // myRate - undefined, // releaseYear - undefined, // releaseSeason - PAGE_SIZE, - "all" - ); - - if (!result?.data?.length) return { items: [], nextCursor: null }; - - return { items: result.data, nextCursor: result.cursor ?? null }; - } catch (err: any) { - if (err.status === 204) return { items: [], nextCursor: null }; - throw err; - } - }; - - // Инициализация: загружаем сразу две страницы - useEffect(() => { - const initLoad = async () => { - setLoadingTitles(true); - setTitles([]); - setNextPage([]); - setCursor(null); - - const firstPage = await fetchPage(null); - const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; - - setTitles(firstPage.items); - setNextPage(secondPage.items); - setCursor(secondPage.nextCursor); - setLoadingTitles(false); - }; - initLoad(); - }, [id, search, sort, sortForward]); - - const handleLoadMore = async () => { - if (nextPage.length === 0) { - setLoadingMore(false); - return; - } - setLoadingMore(true); - - setTitles(prev => [...prev, ...nextPage]); - setNextPage([]); - - if (cursor) { - try { - const next = await fetchPage(cursor); - if (next.items.length > 0) setNextPage(next.items); - setCursor(next.nextCursor); - } catch (err) { - console.error(err); - } - } - - setLoadingMore(false); - }; - - // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); - - return ( - <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> - - {/* --- Карточка пользователя --- */} - {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} - {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} - {user && ( - <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> - <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> - <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> - {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} - {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} - {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} - </div> - )} - - {/* --- Панель поиска, сортировки и лейаута --- */} - <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> - <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> - <LayoutSwitch layout={layout} setLayout={setLayout} /> - <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> - </div> - - {/* --- Список тайтлов --- */} - {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} - {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} - - {titles.length > 0 && ( - <> - <ListView<UserTitle> - items={titles} - layout={layout} - hasMore={!!cursor || nextPage.length > 1} - loadingMore={loadingMore} - onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> - } - /> - - {!cursor && nextPage.length === 0 && ( - <div className="mt-6 font-medium text-black"> - Результатов больше нет, было найдено {titles.length} тайтлов. - </div> - )} - </> - )} - </div> - ); -} From de22dbfb504897da78c1ef60479708ee183530c7 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 10:01:52 +0300 Subject: [PATCH 20/60] feat: title cards linked to title pages --- modules/frontend/src/api/core/OpenAPI.ts | 2 +- .../src/pages/TitlesPage/TitlesPage.module.css | 1 - modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++----- modules/frontend/src/pages/UserPage/UserPage.tsx | 11 ++++++----- 4 files changed, 13 insertions(+), 12 deletions(-) delete mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.module.css diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css deleted file mode 100644 index f1d8c73..0000000 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 0fec3c8..c9911b9 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; 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"; const PAGE_SIZE = 10; @@ -135,11 +136,11 @@ const handleLoadMore = async () => { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } + renderItem={(title, layout) => ( + <Link to={`/titles/${title.id}`} key={title.id} className="block"> + {layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />} + </Link> + )} /> {!cursor && nextPage.length == 0 && ( diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 5fbd6b8..494ba99 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -9,6 +9,7 @@ import { ListView } from "../../components/ListView/ListView"; import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -129,8 +130,6 @@ export default function UserPage({ userId }: UserPageProps) { setLoadingMore(false); }; - // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); - return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> @@ -166,9 +165,11 @@ export default function UserPage({ userId }: UserPageProps) { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} /> - } + renderItem={(title, layout) => ( + <Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block"> + {layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />} + </Link> + )} /> {!cursor && nextPage.length === 0 && ( From ad1c567b42793e743dad1268aac27cec7263508d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 11:59:49 +0300 Subject: [PATCH 21/60] feat: added GetUserTitle route --- api/_build/openapi.yaml | 49 ++- api/api.gen.go | 708 +++++++++++++++++------------- api/openapi.yaml | 2 + api/paths/users-id-titles-id.yaml | 107 +++++ api/paths/users-id-titles.yaml | 84 +--- modules/backend/handlers/users.go | 54 ++- modules/backend/queries.sql | 31 +- sql/queries.sql.go | 100 +++++ 8 files changed, 733 insertions(+), 402 deletions(-) create mode 100644 api/paths/users-id-titles-id.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 2ee6cdc..424e893 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -220,6 +220,7 @@ paths: description: Unknown server error '/users/{user_id}/titles': get: + operationId: getUserTitles summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' @@ -360,6 +361,38 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + '/users/{user_id}/titles/{title_id}': + get: + operationId: getUserTitle + summary: Get user title + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error patch: operationId: updateUserTitle summary: Update a usertitle @@ -367,12 +400,16 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - example: 123 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 requestBody: required: true content: @@ -380,16 +417,11 @@ paths: schema: type: object properties: - title_id: - type: integer - format: int64 status: $ref: '#/components/schemas/UserTitleStatus' rate: type: integer format: int32 - required: - - title_id responses: '200': description: Title successfully updated @@ -414,13 +446,12 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - name: title_id - in: query + in: path required: true schema: type: integer diff --git a/api/api.gen.go b/api/api.gen.go index 6208050..32ab199 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -218,13 +218,8 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// DeleteUserTitleParams defines parameters for DeleteUserTitle. -type DeleteUserTitleParams struct { - TitleId int64 `form:"title_id" json:"title_id"` -} - -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { +// GetUserTitlesParams defines parameters for GetUserTitles. +type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` @@ -241,15 +236,6 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. -type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` - - // Status User's title status - Status *UserTitleStatus `json:"status,omitempty"` - TitleId int64 `json:"title_id"` -} - // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { Rate *int32 `json:"rate,omitempty"` @@ -259,15 +245,23 @@ type AddUserTitleJSONBody struct { TitleId int64 `json:"title_id"` } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody -// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. -type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody - // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody + // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -282,18 +276,21 @@ type ServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(c *gin.Context, userId int64) + GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(c *gin.Context, userId int64, titleId int64) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(c *gin.Context, userId int64, titleId int64) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(c *gin.Context, userId int64, titleId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -505,50 +502,8 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { siw.Handler.UpdateUser(c, userId) } -// DeleteUserTitle operation middleware -func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteUserTitleParams - - // ------------- Required query parameter "title_id" ------------- - - if paramValue := c.Query("title_id"); paramValue != "" { - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument title_id is required, but not found"), http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), ¶ms.TitleId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUserTitle(c, userId, params) -} - -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { +// GetUserTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitles(c *gin.Context) { var err error @@ -562,7 +517,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams + var params GetUserTitlesParams // ------------- Optional query parameter "cursor" ------------- @@ -667,31 +622,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } } - siw.Handler.GetUsersUserIdTitles(c, userId, params) -} - -// UpdateUserTitle operation middleware -func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.UpdateUserTitle(c, userId) + siw.Handler.GetUserTitles(c, userId, params) } // AddUserTitle operation middleware @@ -718,6 +649,105 @@ func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { siw.Handler.AddUserTitle(c, userId) } +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId, titleId) +} + +// GetUserTitle operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUserTitle(c, userId, titleId) +} + +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId, titleId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -749,10 +779,11 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) - router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) - router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) - router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) + router.DELETE(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.DeleteUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.GetUserTitle) + router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } type GetTitlesRequestObject struct { @@ -967,162 +998,55 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri return nil } -type DeleteUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Params DeleteUserTitleParams -} - -type DeleteUserTitleResponseObject interface { - VisitDeleteUserTitleResponse(w http.ResponseWriter) error -} - -type DeleteUserTitle200Response struct { -} - -func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(200) - return nil -} - -type DeleteUserTitle401Response struct { -} - -func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type DeleteUserTitle403Response struct { -} - -func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type DeleteUserTitle404Response struct { -} - -func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type DeleteUserTitle500Response struct { -} - -func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type GetUsersUserIdTitlesRequestObject struct { +type GetUserTitlesRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams + Params GetUserTitlesParams } -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +type GetUserTitlesResponseObject interface { + VisitGetUserTitlesResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse struct { +type GetUserTitles200JSONResponse struct { Cursor CursorObj `json:"cursor"` Data []UserTitle `json:"data"` } -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles200JSONResponse) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserIdTitles204Response struct { +type GetUserTitles204Response struct { } -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles204Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetUsersUserIdTitles400Response struct { +type GetUserTitles400Response struct { } -func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles400Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserIdTitles404Response struct { +type GetUserTitles404Response struct { } -func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles404Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserIdTitles500Response struct { +type GetUserTitles500Response struct { } -func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type UpdateUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Body *UpdateUserTitleJSONRequestBody -} - -type UpdateUserTitleResponseObject interface { - VisitUpdateUserTitleResponse(w http.ResponseWriter) error -} - -type UpdateUserTitle200JSONResponse UserTitleMini - -func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type UpdateUserTitle400Response struct { -} - -func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(400) - return nil -} - -type UpdateUserTitle401Response struct { -} - -func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type UpdateUserTitle403Response struct { -} - -func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type UpdateUserTitle404Response struct { -} - -func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type UpdateUserTitle500Response struct { -} - -func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { +func (response GetUserTitles500Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1193,6 +1117,164 @@ func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.Respons return nil } +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type GetUserTitleResponseObject interface { + VisitGetUserTitleResponse(w http.ResponseWriter) error +} + +type GetUserTitle200JSONResponse UserTitleMini + +func (response GetUserTitle200JSONResponse) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserTitle204Response struct { +} + +func (response GetUserTitle204Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUserTitle400Response struct { +} + +func (response GetUserTitle400Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUserTitle404Response struct { +} + +func (response GetUserTitle404Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUserTitle500Response struct { +} + +func (response GetUserTitle500Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1207,18 +1289,21 @@ type StrictServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) + GetUserTitles(ctx context.Context, request GetUserTitlesRequestObject) (GetUserTitlesResponseObject, error) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(ctx context.Context, request GetUserTitleRequestObject) (GetUserTitleResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1351,18 +1436,18 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } } -// DeleteUserTitle operation middleware -func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) { - var request DeleteUserTitleRequestObject +// GetUserTitles operation middleware +func (sh *strictHandler) GetUserTitles(ctx *gin.Context, userId string, params GetUserTitlesParams) { + var request GetUserTitlesRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + return sh.ssi.GetUserTitles(ctx, request.(GetUserTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUserTitle") + handler = middleware(handler, "GetUserTitles") } response, err := handler(ctx, request) @@ -1370,71 +1455,8 @@ func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { - if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// UpdateUserTitle operation middleware -func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) { - var request UpdateUserTitleRequestObject - - request.UserId = userId - - var body UpdateUserTitleJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "UpdateUserTitle") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { - if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUserTitlesResponseObject); ok { + if err := validResponse.VisitGetUserTitlesResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1476,3 +1498,95 @@ func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUserTitle operation middleware +func (sh *strictHandler) GetUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request GetUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUserTitle(ctx, request.(GetUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUserTitleResponseObject); ok { + if err := validResponse.VisitGetUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 23f2058..08a4d54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -15,6 +15,8 @@ paths: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + /users/{user_id}/titles/{title_id}: + $ref: "./paths/users-id-titles-id.yaml" components: parameters: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml new file mode 100644 index 0000000..b4ad884 --- /dev/null +++ b/api/paths/users-id-titles-id.yaml @@ -0,0 +1,107 @@ +get: + summary: Get user title + operationId: getUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cb7092..75f5461 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -1,5 +1,6 @@ get: summary: Get user titles + operationId: getUserTitles parameters: - $ref: '../parameters/cursor.yaml' - $ref: "../parameters/title_sort.yaml" @@ -138,88 +139,5 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) - '500': - description: Internal server error - -patch: - summary: Update a usertitle - description: User updating title list of watched - operationId: updateUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - example: 123 - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - title_id - properties: - title_id: - type: integer - format: int64 - status: - $ref: '../schemas/enums/UserTitleStatus.yaml' - rate: - type: integer - format: int32 - - responses: - '200': - description: Title successfully updated - content: - application/json: - schema: - $ref: '../schemas/UserTitleMini.yaml' - '400': - description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to update title - '404': - description: User or Title not found - '500': - description: Internal server error - -delete: - summary: Delete a usertitle - description: User deleting title from list of watched - operationId: deleteUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - - in: query - name: title_id - required: true - schema: - type: integer - format: int64 - - - responses: - '200': - description: Title successfully deleted - # '400': - # description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to delete title - '404': - description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 563a244..8723d16 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -204,7 +204,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o return oapi_usertitle, nil } -func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { +func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) { oapi_usertitles := make([]oapi.UserTitle, 0) @@ -213,7 +213,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } // var statuses_sort []string @@ -227,19 +227,19 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } title_statuses, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } userID, err := parseInt64(request.UserId) if err != nil { log.Errorf("get user titles: %v", err) - return oapi.GetUsersUserIdTitles404Response{}, err + return oapi.GetUserTitles404Response{}, err } params := sqlc.SearchUserTitlesParams{ UserID: userID, @@ -265,7 +265,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, nil + return oapi.GetUserTitles400Response{}, nil } } } @@ -273,10 +273,10 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU titles, err := s.db.SearchUserTitles(ctx, params) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } if len(titles) == 0 { - return oapi.GetUsersUserIdTitles204Response{}, nil + return oapi.GetUserTitles204Response{}, nil } var new_cursor oapi.CursorObj @@ -286,7 +286,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU t, err := s.mapUsertitle(ctx, title) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } oapi_usertitles = append(oapi_usertitles, t) @@ -303,7 +303,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU } } - return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil + return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } func EmailToStringPtr(e *types.Email) *string { @@ -402,7 +402,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { params := sqlc.DeleteUserTitleParams{ UserID: request.UserId, - TitleID: request.Params.TitleId, + TitleID: request.TitleId, } _, err := s.db.DeleteUserTitle(ctx, params) if err != nil { @@ -427,7 +427,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Status: status, Rate: request.Body.Rate, UserID: request.UserId, - TitleID: request.Body.TitleId, + TitleID: request.TitleId, } user_title, err := s.db.UpdateUserTitle(ctx, params) @@ -455,3 +455,33 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil } + +func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) { + user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{ + TitleID: request.TitleId, + UserID: request.UserId, + }) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUserTitle404Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: *user_title.ID, + UserId: user_title.UserID, + } + + return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 5ac2c5c..1a90cde 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -394,4 +394,33 @@ RETURNING *; DELETE FROM usertitles WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') -RETURNING *; \ No newline at end of file +RETURNING *; + +-- name: GetUserTitleByID :one +SELECT + ut.*, + t.*, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..f35007d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -262,6 +262,106 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserTitleByID = `-- name: GetUserTitleByID :one +SELECT + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = $1::bigint AND u.id = $2::bigint +GROUP BY + t.id, i.id, s.id, si.id +` + +type GetUserTitleByIDParams struct { + TitleID int64 `json:"title_id"` + UserID int64 `json:"user_id"` +} + +type GetUserTitleByIDRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` + ID *int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID *int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus *TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { + row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) + var i GetUserTitleByIDRow + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( From 13342d5613e20c9bf4ece9700ff085de8029b090 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 13:18:19 +0300 Subject: [PATCH 22/60] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 87f3655..adbe61e 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -18,9 +18,6 @@ jobs: - uses: actions/setup-go@v6 with: go-version: '^1.25' - check-latest: false - cache-dependency-path: | - go.sum - name: Build backend run: | From 3f0456ba01b20b016e0bf9142eb44a605a770f98 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 14:09:13 +0300 Subject: [PATCH 23/60] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index adbe61e..3c473d2 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -101,7 +101,7 @@ jobs: tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest deploy: - runs-on: self-hosted + runs-on: debian-test needs: build env: POSTGRES_USER: ${{ secrets.POSTGRES_USER }} From 8a3e14a5e5c0495be790ab1dbcd4832fd0f41fb0 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 16:26:03 +0300 Subject: [PATCH 24/60] feat: TitleStatusControls --- .../src/api/services/DefaultService.ts | 62 +++++++++++- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../TitleStatusControls.tsx | 88 +++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 94 ++++++------------- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- 5 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 5070fae..218b461 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -199,7 +199,7 @@ export class DefaultService { * @returns any List of user titles * @throws ApiError */ - public static getUsersTitles( + public static getUserTitles( userId: string, cursor?: string, sort?: TitleSort, @@ -278,27 +278,54 @@ export class DefaultService { }, }); } + /** + * Get user title + * @param userId + * @param titleId + * @returns UserTitleMini User titles + * @throws ApiError + */ + public static getUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 400: `Request params are not correct`, + 404: `User or title not found`, + 500: `Unknown server error`, + }, + }); + } /** * Update a usertitle * User updating title list of watched - * @param userId ID of the user to assign the title to + * @param userId + * @param titleId * @param requestBody * @returns UserTitleMini Title successfully updated * @throws ApiError */ public static updateUserTitle( userId: number, + titleId: number, requestBody: { - title_id: number; status?: UserTitleStatus; rate?: number; }, ): CancelablePromise<UserTitleMini> { return __request(OpenAPI, { method: 'PATCH', - url: '/users/{user_id}/titles', + url: '/users/{user_id}/titles/{title_id}', path: { 'user_id': userId, + 'title_id': titleId, }, body: requestBody, mediaType: 'application/json', @@ -311,4 +338,31 @@ export class DefaultService { }, }); } + /** + * Delete a usertitle + * User deleting title from list of watched + * @param userId + * @param titleId + * @returns any Title successfully deleted + * @throws ApiError + */ + public static deleteUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<any> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to delete title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 79aa305..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx new file mode 100644 index 0000000..0c9c741 --- /dev/null +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { DefaultService } from "../../api"; +import type { UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +// Статусы с иконками и подписью +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" }, +]; + +export function TitleStatusControls({ titleId }: { titleId: number }) { + const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); + const [loading, setLoading] = useState(false); + + const userIdStr = localStorage.getItem("userId"); + const userId = userIdStr ? Number(userIdStr) : null; + + // --- Load initial status --- + useEffect(() => { + if (!userId) return; + + DefaultService.getUserTitle(userId, titleId) + .then((res) => setCurrentStatus(res.status)) + .catch(() => setCurrentStatus(null)); // 404 = user title does not exist + }, [titleId, userId]); + + // --- Handle click --- + const handleStatusClick = async (status: UserTitleStatus) => { + if (!userId || loading) return; + + setLoading(true); + + try { + // 1) Если кликнули на текущий статус — DELETE + if (currentStatus === status) { + await DefaultService.deleteUserTitle(userId, titleId); + setCurrentStatus(null); + return; + } + + // 2) Если другой статус — POST или PATCH + if (!currentStatus) { + // ещё нет записи — POST + const added = await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setCurrentStatus(added.status); + } else { + // уже есть запись — PATCH + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + setCurrentStatus(updated.status); + } + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex gap-2 flex-wrap justify-center mt-2"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={loading} + className={` + px-3 py-1 rounded-md border flex items-center gap-1 transition + ${currentStatus === btn.status + ? "bg-blue-600 text-white border-blue-700" + : "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"} + `} + title={btn.label} + > + {btn.icon} + <span>{btn.label}</span> + </button> + ))} + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 5ea0e3d..01f9c49 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,20 +1,8 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, Link } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title, UserTitleStatus } from "../../api"; -import { - ClockIcon, - CheckCircleIcon, - PlayCircleIcon, - XCircleIcon, -} from "@heroicons/react/24/solid"; - -const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ - { status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" }, - { status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" }, - { status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" }, - { status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" }, -]; +import type { Title } from "../../api"; +import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; export default function TitlePage() { const params = useParams(); @@ -24,9 +12,9 @@ export default function TitlePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); - const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null); - const [updatingStatus, setUpdatingStatus] = useState(false); - + // --------------------------- + // LOAD TITLE INFO + // --------------------------- useEffect(() => { const fetchTitle = async () => { setLoading(true); @@ -44,30 +32,6 @@ export default function TitlePage() { fetchTitle(); }, [titleId]); - const handleStatusClick = async (status: UserTitleStatus) => { - if (updatingStatus || userStatus === status) return; - - const userId = Number(localStorage.getItem("userId")); - if (!userId) { - alert("You must be logged in to set status."); - return; - } - - setUpdatingStatus(true); - try { - await DefaultService.addUserTitle(userId, { - title_id: titleId, - status, - }); - setUserStatus(status); - } catch (err: any) { - console.error(err); - alert(err?.message || "Failed to set status"); - } finally { - setUpdatingStatus(false); - } - }; - const getTagsString = () => title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); @@ -78,7 +42,7 @@ export default function TitlePage() { return ( <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> - {/* Постер */} + {/* Poster + status buttons */} <div className="flex flex-col items-center"> <img src={title.poster?.image_path || "/default-poster.png"} @@ -86,48 +50,52 @@ export default function TitlePage() { className="w-48 h-72 object-cover rounded-lg mb-4" /> - {/* Статус кнопки с иконками */} - <div className="flex gap-2 mt-2 flex-wrap justify-center"> - {STATUS_BUTTONS.map(btn => ( - <button - key={btn.status} - onClick={() => handleStatusClick(btn.status)} - disabled={updatingStatus} - className={`p-2 rounded-lg transition flex items-center justify-center ${ - userStatus === btn.status - ? "bg-blue-600 text-white" - : "bg-gray-200 text-gray-700 hover:bg-gray-300" - }`} - title={btn.label} - > - {btn.icon} - </button> - ))} - </div> + {/* Status buttons */} + <TitleStatusControls titleId={titleId} /> </div> - {/* Информация о тайтле */} + {/* Title info */} <div className="flex-1 flex flex-col"> <h1 className="text-3xl font-bold mb-2"> {title.title_names?.en?.[0] || "Untitled"} </h1> - {title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>} + + {title.studio && ( + <p className="text-gray-700 mb-1"> + Studio:{" "} + {title.studio.id ? ( + <Link + to={`/studios/${title.studio.id}`} + className="text-blue-600 hover:underline" + > + {title.studio.name} + </Link> + ) : ( + title.studio.name + )} + </p> + )} + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + {title.rating !== undefined && ( <p className="text-gray-700 mb-1"> Rating: {title.rating} ({title.rating_count} votes) </p> )} + {title.release_year && ( <p className="text-gray-700 mb-1"> Released: {title.release_year} {title.release_season || ""} </p> )} + {title.episodes_aired !== undefined && ( <p className="text-gray-700 mb-1"> Episodes: {title.episodes_aired}/{title.episodes_all} </p> )} + {title.tags && title.tags.length > 0 && ( <p className="text-gray-700 mb-1"> Tags: {getTagsString()} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 494ba99..7cc0db5 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -63,7 +63,7 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUsersTitles( + const result = await DefaultService.getUserTitles( id, cursorStr, sort, From 37cdc32d5da55d620cc82eb2caf3b6de28dcab57 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 27 Nov 2025 16:28:09 +0300 Subject: [PATCH 25/60] fix: fix GetUserTitleByID --- modules/backend/handlers/users.go | 2 +- modules/backend/main.go | 2 +- modules/backend/queries.sql | 27 +--------- sql/queries.sql.go | 82 ++----------------------------- 4 files changed, 8 insertions(+), 105 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 8723d16..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -479,7 +479,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, - TitleId: *user_title.ID, + TitleId: user_title.TitleID, UserId: user_title.UserID, } diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..8f58ffe 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -48,7 +48,7 @@ func main() { r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1a90cde..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -398,29 +398,6 @@ RETURNING *; -- name: GetUserTitleByID :one SELECT - ut.*, - t.*, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.* FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint -GROUP BY - t.id, i.id, s.id, si.id; \ No newline at end of file +WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index ddf6f6b..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -306,32 +306,9 @@ func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, const getUserTitleByID = `-- name: GetUserTitleByID :one SELECT - ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = $1::bigint AND u.id = $2::bigint -GROUP BY - t.id, i.id, s.id, si.id +WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` type GetUserTitleByIDParams struct { @@ -339,39 +316,9 @@ type GetUserTitleByIDParams struct { UserID int64 `json:"user_id"` } -type GetUserTitleByIDRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType *StorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType *StorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` -} - -func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) - var i GetUserTitleByIDRow + var i Usertitle err := row.Scan( &i.UserID, &i.TitleID, @@ -379,27 +326,6 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Rate, &i.ReviewID, &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - &i.TitleStorageType, - &i.TitleImagePath, - &i.TagNames, - &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ) return i, err } From f71c1f4f082bfd6914cfcf2d3f879e3b3b7b05db Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Fri, 28 Nov 2025 11:43:10 +0300 Subject: [PATCH 26/60] feat: added rabbitmq --- deploy/docker-compose.yml | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7f53da5..79ad2f5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,20 +11,34 @@ services: - "${POSTGRES_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql + networks: + - nyanimedb-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s - # pgadmin: - # image: dpage/pgadmin4:${PGADMIN_VERSION} - # container_name: pgadmin - # restart: always - # environment: - # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - # ports: - # - "${PGADMIN_PORT}:80" - # depends_on: - # - postgres - # volumes: - # - pgadmin_data:/var/lib/pgadmin + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - nyanimedb-network + healthcheck: + test: ["CMD", "rabbitmqctl", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest @@ -37,6 +51,9 @@ services: - "8080:8080" depends_on: - postgres + - rabbitmq + networks: + - nyanimedb-network nyanimedb-auth: image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest @@ -49,6 +66,8 @@ services: - "8082:8082" depends_on: - postgres + networks: + - nyanimedb-network nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest @@ -58,7 +77,12 @@ services: - "8081:80" depends_on: - nyanimedb-backend + networks: + - nyanimedb-network volumes: postgres_data: - pgadmin_data: + rabbitmq_data: + +networks: + nyanimedb-network: From 1756d61da466a70fdbe0f3dce4b963f197fc92c9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 00:51:38 +0300 Subject: [PATCH 27/60] lib for rabbitMQ --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 7b7cc71..fe4f31e 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index cd197e6..6704a5a 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From c6cebb0ed24e2c26b2ce72ac0d618db0c7df0c7c Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 01:34:59 +0300 Subject: [PATCH 28/60] feat: rabbitMQ request --- api/_build/openapi.yaml | 5 ++ api/api.gen.go | 9 ++++ api/paths/titles.yaml | 6 ++- go.mod | 1 + go.sum | 2 + modules/backend/rabbit.go | 103 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 modules/backend/rabbit.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 2ee6cdc..f875ba2 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -16,6 +16,11 @@ paths: schema: type: boolean default: true + - name: ext_search + in: query + schema: + type: boolean + default: false - name: word in: query schema: diff --git a/api/api.gen.go b/api/api.gen.go index 6208050..2294d74 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -178,6 +178,7 @@ type GetTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + ExtSearch *bool `form:"ext_search,omitempty" json:"ext_search,omitempty"` Word *string `form:"word,omitempty" json:"word,omitempty"` // Status List of title statuses to filter @@ -337,6 +338,14 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { return } + // ------------- Optional query parameter "ext_search" ------------- + + err = runtime.BindQueryParameter("form", true, false, "ext_search", c.Request.URL.Query(), ¶ms.ExtSearch) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter ext_search: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index af2d17b..4288417 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -8,6 +8,11 @@ get: schema: type: boolean default: true + - in: query + name: ext_search + schema: + type: boolean + default: false - in: query name: word schema: @@ -21,7 +26,6 @@ get: description: List of title statuses to filter style: form explode: false - - in: query name: rating schema: diff --git a/go.mod b/go.mod index bf73121..7fc0e5f 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index 8f46514..e52e5c9 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/modules/backend/rabbit.go b/modules/backend/rabbit.go new file mode 100644 index 0000000..f08bf39 --- /dev/null +++ b/modules/backend/rabbit.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/streadway/amqp" +) + +type RabbitRequest struct { + Name string `json:"name"` + Status oapi.TitleStatus `json:"titlestatus,omitempty"` + Rating float64 `json:"titleraring,omitempty"` + Year int32 `json:"year,omitempty"` + Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// PublishAndAwaitReply отправляет запрос и ждёт ответа от worker’а. +// Возвращает раскодированный ответ или ошибку. +func PublishAndAwaitReply( + ctx context.Context, + ch *amqp.Channel, + requestQueue string, // например: "svc.media.process.requests" + request RabbitRequest, // ваша структура запроса + replyCh chan<- any, // куда положить ответ (вы читаете извне) +) error { + // 1. Создаём временную очередь для ответов + replyQueue, err := ch.QueueDeclare( + "", // auto-generated name + false, // not durable + true, // exclusive + true, // auto-delete + false, // no-wait + nil, + ) + if err != nil { + return fmt.Errorf("failed to declare reply queue: %w", err) + } + + // 2. Готовим корреляционный ID + corrID := uuid.New().String() // ← используйте github.com/google/uuid + logrus.Infof("New CorrID: %s", corrID) + + // 3. Сериализуем запрос + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // 4. Публикуем запрос + err = ch.Publish( + "", // default exchange (или свой, если используете) + requestQueue, + false, + false, + amqp.Publishing{ + ContentType: "application/json", + CorrelationId: corrID, + ReplyTo: replyQueue.Name, + DeliveryMode: amqp.Persistent, + Timestamp: time.Now(), + Body: body, + }, + ) + if err != nil { + return fmt.Errorf("failed to publish request, corrID: %s : %w", corrID, err) + } + + // 5. Подписываемся на ответы + msgs, err := ch.Consume( + replyQueue.Name, + "", // consumer tag + true, // auto-ack + true, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + return fmt.Errorf("failed to consume from reply queue: %w", err) + } + + // 6. Ожидаем ответ с таймаутом + select { + case msg := <-msgs: + if msg.CorrelationId != corrID { + return fmt.Errorf("correlation ID mismatch: got %s, expected %s", msg.CorrelationId, corrID) + } + // Десериализуем — тут можно передать target-структуру или использовать interface{} + // В данном случае просто возвращаем байты или пусть вызывающая сторона парсит + replyCh <- msg.Body // или json.Unmarshal → и отправить структуру в канал + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} From 77a63a1c748e073470ed19b355e5fd1860955597 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 02:57:11 +0300 Subject: [PATCH 29/60] feat: rabbitMQ is now calling from seatchtitles --- go.mod | 1 + go.sum | 2 + modules/backend/handlers/common.go | 21 +++- modules/backend/handlers/titles.go | 25 +++++ modules/backend/main.go | 43 +++++--- modules/backend/rabbit.go | 103 ------------------ modules/backend/rmq/rabbit.go | 166 +++++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 124 deletions(-) delete mode 100644 modules/backend/rabbit.go create mode 100644 modules/backend/rmq/rabbit.go diff --git a/go.mod b/go.mod index 7fc0e5f..f29cbb1 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect diff --git a/go.sum b/go.sum index e52e5c9..59cc7ba 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index f820db6..aece414 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -4,16 +4,29 @@ import ( "encoding/json" "fmt" oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" "strconv" ) -type Server struct { - db *sqlc.Queries +type Handler struct { + publisher *rmq.Publisher } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func New(publisher *rmq.Publisher) *Handler { + return &Handler{publisher: publisher} +} + +type Server struct { + db *sqlc.Queries + publisher *rmq.Publisher // ← добавьте это поле +} + +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher) *Server { + return &Server{ + db: db, + publisher: publisher, + } } func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 77af7e4..9f11016 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" "strconv" + "time" "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" @@ -154,6 +156,29 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject } func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { + + if request.Params.ExtSearch != nil && *request.Params.ExtSearch { + // Публикуем событие — как и просили + event := rmq.RabbitRequest{ + Name: "Attack on titans", + // Status oapi.TitleStatus `json:"titlestatus,omitempty"` + // Rating float64 `json:"titleraring,omitempty"` + // Year int32 `json:"year,omitempty"` + // Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp: time.Now(), + } + + // Контекст с таймаутом (не блокируем ответ) + publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := s.publisher.Publish(publishCtx, "events.user", event); err != nil { + log.Errorf("RMQ publish failed (non-critical): %v", err) + } else { + log.Infof("RMQ publish succeed %v", err) + } + } + opai_titles := make([]oapi.Title, 0) word := Word2Sqlc(request.Params.Word) diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..25f175a 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/http" sqlc "nyanimedb/sql" "os" "reflect" @@ -10,11 +11,14 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" + "github.com/rabbitmq/amqp091-go" + log "github.com/sirupsen/logrus" ) var AppConfig Config @@ -43,7 +47,21 @@ func main() { queries := sqlc.New(pool) - server := handlers.NewServer(queries) + // === RabbitMQ setup === + rmqURL := os.Getenv("RABBITMQ_URL") + if rmqURL == "" { + rmqURL = "amqp://guest:guest@10.1.0.65:5672/" + } + + rmqConn, err := amqp091.Dial(rmqURL) + if err != nil { + log.Fatalf("Failed to connect to RabbitMQ: %v", err) + } + defer rmqConn.Close() + + publisher := rmq.NewPublisher(rmqConn) + + server := handlers.NewServer(queries, publisher) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -60,24 +78,15 @@ func main() { // сюда можно добавить middlewares, если нужно []oapi.StrictMiddlewareFunc{}, )) - // r.GET("/", func(c *gin.Context) { - // c.HTML(http.StatusOK, "index.html", gin.H{ - // "title": "Welcome Page", - // "message": "Hello, Gin with HTML templates!", - // }) - // }) - // r.GET("/api", func(c *gin.Context) { - // items := []Item{ - // {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, - // {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, - // {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, - // } + // Внедряем publisher в сервер + server = handlers.NewServer(queries, publisher) - // c.JSON(http.StatusOK, items) - // }) - - r.Run(":8080") + // Запуск + log.Infof("Server starting on :8080") + if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } } func InitConfig() error { diff --git a/modules/backend/rabbit.go b/modules/backend/rabbit.go deleted file mode 100644 index f08bf39..0000000 --- a/modules/backend/rabbit.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - oapi "nyanimedb/api" - "time" - - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/streadway/amqp" -) - -type RabbitRequest struct { - Name string `json:"name"` - Status oapi.TitleStatus `json:"titlestatus,omitempty"` - Rating float64 `json:"titleraring,omitempty"` - Year int32 `json:"year,omitempty"` - Season oapi.ReleaseSeason `json:"season,omitempty"` - Timestamp time.Time `json:"timestamp"` -} - -// PublishAndAwaitReply отправляет запрос и ждёт ответа от worker’а. -// Возвращает раскодированный ответ или ошибку. -func PublishAndAwaitReply( - ctx context.Context, - ch *amqp.Channel, - requestQueue string, // например: "svc.media.process.requests" - request RabbitRequest, // ваша структура запроса - replyCh chan<- any, // куда положить ответ (вы читаете извне) -) error { - // 1. Создаём временную очередь для ответов - replyQueue, err := ch.QueueDeclare( - "", // auto-generated name - false, // not durable - true, // exclusive - true, // auto-delete - false, // no-wait - nil, - ) - if err != nil { - return fmt.Errorf("failed to declare reply queue: %w", err) - } - - // 2. Готовим корреляционный ID - corrID := uuid.New().String() // ← используйте github.com/google/uuid - logrus.Infof("New CorrID: %s", corrID) - - // 3. Сериализуем запрос - body, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // 4. Публикуем запрос - err = ch.Publish( - "", // default exchange (или свой, если используете) - requestQueue, - false, - false, - amqp.Publishing{ - ContentType: "application/json", - CorrelationId: corrID, - ReplyTo: replyQueue.Name, - DeliveryMode: amqp.Persistent, - Timestamp: time.Now(), - Body: body, - }, - ) - if err != nil { - return fmt.Errorf("failed to publish request, corrID: %s : %w", corrID, err) - } - - // 5. Подписываемся на ответы - msgs, err := ch.Consume( - replyQueue.Name, - "", // consumer tag - true, // auto-ack - true, // exclusive - false, // no-local - false, // no-wait - nil, // args - ) - if err != nil { - return fmt.Errorf("failed to consume from reply queue: %w", err) - } - - // 6. Ожидаем ответ с таймаутом - select { - case msg := <-msgs: - if msg.CorrelationId != corrID { - return fmt.Errorf("correlation ID mismatch: got %s, expected %s", msg.CorrelationId, corrID) - } - // Десериализуем — тут можно передать target-структуру или использовать interface{} - // В данном случае просто возвращаем байты или пусть вызывающая сторона парсит - replyCh <- msg.Body // или json.Unmarshal → и отправить структуру в канал - return nil - - case <-ctx.Done(): - return ctx.Err() - } -} diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go new file mode 100644 index 0000000..85df89b --- /dev/null +++ b/modules/backend/rmq/rabbit.go @@ -0,0 +1,166 @@ +package rmq + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitRequest struct { + Name string `json:"name"` + Status oapi.TitleStatus `json:"titlestatus,omitempty"` + Rating float64 `json:"titleraring,omitempty"` + Year int32 `json:"year,omitempty"` + Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Publisher — потокобезопасный публикатор с пулом каналов. +type Publisher struct { + conn *amqp.Connection + pool *sync.Pool +} + +// NewPublisher создаёт новый Publisher. +// conn должен быть уже установленным и healthy. +// Рекомендуется передавать durable connection с reconnect-логикой. +func NewPublisher(conn *amqp.Connection) *Publisher { + return &Publisher{ + conn: conn, + pool: &sync.Pool{ + New: func() any { + ch, err := conn.Channel() + if err != nil { + // Паника уместна: невозможность открыть канал — критическая ошибка инициализации + panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) + } + return ch + }, + }, + } +} + +// Publish публикует сообщение в указанную очередь. +// Очередь объявляется как durable (если не существует). +// Поддерживает context для отмены/таймаута. +func (p *Publisher) Publish( + ctx context.Context, + queueName string, + payload RabbitRequest, + opts ...PublishOption, +) error { + // Применяем опции + options := &publishOptions{ + contentType: "application/json", + deliveryMode: amqp.Persistent, + timestamp: time.Now(), + } + for _, opt := range opts { + opt(options) + } + + // Сериализуем payload + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) + } + + // Берём канал из пула + ch := p.getChannel() + if ch == nil { + return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") + } + defer p.returnChannel(ch) + + // Объявляем очередь (idempotent) + q, err := ch.QueueDeclare( + queueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, // args + ) + if err != nil { + return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) + } + + // Подготавливаем сообщение + msg := amqp.Publishing{ + DeliveryMode: options.deliveryMode, + ContentType: options.contentType, + Timestamp: options.timestamp, + Body: body, + } + + // Публикуем с учётом контекста + done := make(chan error, 1) + go func() { + err := ch.Publish( + "", // exchange (default) + q.Name, // routing key + false, // mandatory + false, // immediate + msg, + ) + done <- err + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *Publisher) getChannel() *amqp.Channel { + raw := p.pool.Get() + if raw == nil { + ch, _ := p.conn.Channel() + return ch + } + ch := raw.(*amqp.Channel) + if ch.IsClosed() { // ← теперь есть! + ch.Close() // освободить ресурсы + ch, _ = p.conn.Channel() + } + return ch +} + +// returnChannel возвращает канал в пул, если он жив. +func (p *Publisher) returnChannel(ch *amqp.Channel) { + if ch != nil && !ch.IsClosed() { + p.pool.Put(ch) + } +} + +// PublishOption позволяет кастомизировать публикацию. +type PublishOption func(*publishOptions) + +type publishOptions struct { + contentType string + deliveryMode uint8 + timestamp time.Time +} + +// WithContentType устанавливает Content-Type (по умолчанию "application/json"). +func WithContentType(ct string) PublishOption { + return func(o *publishOptions) { o.contentType = ct } +} + +// WithTransient делает сообщение transient (не сохраняется на диск). +// По умолчанию — Persistent. +func WithTransient() PublishOption { + return func(o *publishOptions) { o.deliveryMode = amqp.Transient } +} + +// WithTimestamp устанавливает кастомную метку времени. +func WithTimestamp(ts time.Time) PublishOption { + return func(o *publishOptions) { o.timestamp = ts } +} From a29aefbe977d826383b00e74088013b4299482bc Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 03:20:52 +0300 Subject: [PATCH 30/60] fix --- modules/backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 7b995a5..13be887 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -50,7 +50,7 @@ func main() { // === RabbitMQ setup === rmqURL := os.Getenv("RABBITMQ_URL") if rmqURL == "" { - rmqURL = "amqp://guest:guest@10.1.0.65:5672/" + rmqURL = "amqp://guest:guest@rabbitmq:5672/" } rmqConn, err := amqp091.Dial(rmqURL) From ab29c33f5b522b68045b174cf06ddf33942468f1 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Sun, 30 Nov 2025 04:02:28 +0300 Subject: [PATCH 31/60] feat: now back wait for RMQ answer --- modules/backend/handlers/common.go | 6 +- modules/backend/handlers/titles.go | 62 ++++++++++++------- modules/backend/main.go | 6 +- modules/backend/rmq/rabbit.go | 99 +++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 30 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index aece414..cad4f0f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -19,13 +19,15 @@ func New(publisher *rmq.Publisher) *Handler { type Server struct { db *sqlc.Queries - publisher *rmq.Publisher // ← добавьте это поле + publisher *rmq.Publisher + RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, publisher *rmq.Publisher) *Server { +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { return &Server{ db: db, publisher: publisher, + RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 9f11016..300cc87 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -157,43 +157,61 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { - if request.Params.ExtSearch != nil && *request.Params.ExtSearch { - // Публикуем событие — как и просили - event := rmq.RabbitRequest{ - Name: "Attack on titans", - // Status oapi.TitleStatus `json:"titlestatus,omitempty"` - // Rating float64 `json:"titleraring,omitempty"` - // Year int32 `json:"year,omitempty"` - // Season oapi.ReleaseSeason `json:"season,omitempty"` - Timestamp: time.Now(), - } - - // Контекст с таймаутом (не блокируем ответ) - publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if err := s.publisher.Publish(publishCtx, "events.user", event); err != nil { - log.Errorf("RMQ publish failed (non-critical): %v", err) - } else { - log.Infof("RMQ publish succeed %v", err) - } + opai_titles := make([]oapi.Title, 0) + mqreq := rmq.RabbitRequest{ + Timestamp: time.Now(), } - opai_titles := make([]oapi.Title, 0) - word := Word2Sqlc(request.Params.Word) + if word != nil { + mqreq.Name = *word + } season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + if season != nil { + mqreq.Season = *request.Params.ReleaseSeason + } title_statuses, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } + if title_statuses != nil { + mqreq.Statuses = *request.Params.Status + } + + if request.Params.ExtSearch != nil && *request.Params.ExtSearch { + + // Структура для ответа (должна совпадать с тем, что шлёт микросервис) + var reply struct { + Status string `json:"status"` + Result string `json:"result"` + Preview string `json:"preview_url"` + } + + // Делаем RPC-вызов — и ЖДЁМ ответа + err := s.RPCclient.Call( + ctx, + "svc.media.process.requests", // ← очередь микросервиса + mqreq, + &reply, + ) + if err != nil { + log.Errorf("RabitMQ: %v", err) + // return oapi.GetTitles500Response{}, err + } + // // Возвращаем результат + // return oapi.ProcessMedia200JSONResponse{ + // Status: reply.Status, + // Result: reply.Result, + // Preview: reply.Preview, + // }, nil + } params := sqlc.SearchTitlesParams{ Word: word, diff --git a/modules/backend/main.go b/modules/backend/main.go index 13be887..9f992a5 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -60,8 +60,9 @@ func main() { defer rmqConn.Close() publisher := rmq.NewPublisher(rmqConn) + rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, publisher) + server := handlers.NewServer(queries, publisher, rpcClient) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -79,9 +80,6 @@ func main() { []oapi.StrictMiddlewareFunc{}, )) - // Внедряем publisher в сервер - server = handlers.NewServer(queries, publisher) - // Запуск log.Infof("Server starting on :8080") if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go index 85df89b..52c1979 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -13,8 +13,8 @@ import ( type RabbitRequest struct { Name string `json:"name"` - Status oapi.TitleStatus `json:"titlestatus,omitempty"` - Rating float64 `json:"titleraring,omitempty"` + Statuses []oapi.TitleStatus `json:"statuses,omitempty"` + Rating float64 `json:"rating,omitempty"` Year int32 `json:"year,omitempty"` Season oapi.ReleaseSeason `json:"season,omitempty"` Timestamp time.Time `json:"timestamp"` @@ -164,3 +164,98 @@ func WithTransient() PublishOption { func WithTimestamp(ts time.Time) PublishOption { return func(o *publishOptions) { o.timestamp = ts } } + +type RPCClient struct { + conn *amqp.Connection + timeout time.Duration +} + +func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { + return &RPCClient{conn: conn, timeout: timeout} +} + +// Call отправляет запрос в очередь и ждёт ответа. +// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). +func (c *RPCClient) Call( + ctx context.Context, + requestQueue string, + request RabbitRequest, + replyPayload any, +) error { + // 1. Создаём временный канал (не из пула!) + ch, err := c.conn.Channel() + if err != nil { + return fmt.Errorf("channel: %w", err) + } + defer ch.Close() + + // 2. Создаём временную очередь для ответов + q, err := ch.QueueDeclare( + "", // auto name + false, // not durable + true, // exclusive + true, // auto-delete + false, + nil, + ) + if err != nil { + return fmt.Errorf("reply queue: %w", err) + } + + // 3. Подписываемся на ответы + msgs, err := ch.Consume( + q.Name, + "", + true, // auto-ack + true, // exclusive + false, + false, + nil, + ) + if err != nil { + return fmt.Errorf("consume: %w", err) + } + + // 4. Готовим correlation ID + corrID := time.Now().UnixNano() + + // 5. Сериализуем запрос + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + // 6. Публикуем запрос + err = ch.Publish( + "", + requestQueue, + false, + false, + amqp.Publishing{ + ContentType: "application/json", + CorrelationId: fmt.Sprintf("%d", corrID), + ReplyTo: q.Name, + Timestamp: time.Now(), + Body: body, + }, + ) + if err != nil { + return fmt.Errorf("publish: %w", err) + } + + // 7. Ждём ответ с таймаутом + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + for { + select { + case msg := <-msgs: + if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + return json.Unmarshal(msg.Body, replyPayload) + } + // игнорируем другие сообщения (маловероятно, но возможно) + case <-ctx.Done(): + return ctx.Err() // timeout or cancelled + } + } +} From 4dd60f3b190ffa8b2ca91ade49d83c1b36ea295d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 05:52:31 +0300 Subject: [PATCH 32/60] feat: TitlesFilterPanel component --- .../src/api/services/DefaultService.ts | 3 + .../TitlesFilterPanel/TitlesFilterPanel.tsx | 122 ++++++++++++++++++ .../src/pages/TitlesPage/TitlesPage.tsx | 23 +++- 3 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx 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<TitleStatus>, 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/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 ( + <div className="w-full flex justify-center my-4"> + <div className="bg-white shadow rounded-lg w-full max-w-3xl p-4"> + {/* Заголовок панели */} + <div + className="flex justify-between items-center cursor-pointer" + onClick={() => setOpen((prev) => !prev)} + > + <h3 className="text-lg font-medium">Filters</h3> + {open ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />} + </div> + + {/* Контент панели */} + {open && ( + <div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-4"> + {/* Extended Search */} + <div className="flex items-center gap-2"> + <input + type="checkbox" + id="extSearch" + checked={filters.extSearch} + onChange={(e) => handleChange("extSearch", e.target.checked)} + className="w-4 h-4" + /> + <label htmlFor="extSearch" className="text-sm"> + Extended Search + </label> + </div> + + {/* Status */} + <div className="flex flex-col"> + <label htmlFor="status" className="text-sm mb-1">Status</label> + <select + id="status" + value={filters.status} + onChange={(e) => handleChange("status", e.target.value || "")} + className="border rounded px-2 py-1" + > + {STATUS_OPTIONS.map((s) => ( + <option key={s || "all"} value={s}>{s || "All"}</option> + ))} + </select> + </div> + + {/* Rating */} + <div className="flex flex-col"> + <label htmlFor="rating" className="text-sm mb-1">Rating</label> + <select + id="rating" + value={filters.rating} + onChange={(e) => handleChange("rating", e.target.value ? Number(e.target.value) : "")} + className="border rounded px-2 py-1" + > + {RATING_OPTIONS.map((r) => ( + <option key={r} value={r}>{r || "All"}</option> + ))} + </select> + </div> + + {/* Release Year */} + <div className="flex flex-col"> + <label htmlFor="releaseYear" className="text-sm mb-1">Release Year</label> + <input + type="number" + id="releaseYear" + value={filters.releaseYear || ""} + onChange={(e) => + handleChange("releaseYear", e.target.value ? Number(e.target.value) : "") + } + className="border rounded px-2 py-1" + placeholder="Any" + /> + </div> + + {/* Release Season */} + <div className="flex flex-col"> + <label htmlFor="releaseSeason" className="text-sm mb-1">Release Season</label> + <select + id="releaseSeason" + value={filters.releaseSeason} + onChange={(e) => handleChange("releaseSeason", e.target.value || "")} + className="border rounded px-2 py-1" + > + {SEASON_OPTIONS.map((s) => ( + <option key={s || "all"} value={s}>{s || "All"}</option> + ))} + </select> + </div> + </div> + )} + </div> + </div> + ); +} 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<TitlesFilter>({ + 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} /> </div> + <TitlesFilterPanel filters={filters} setFilters={setFilters} /> {loading && <div className="mt-20 font-medium text-black">Loading...</div>} From 6995ce58f6d8f588f235cbaf985b7b82e76ecda1 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 06:13:03 +0300 Subject: [PATCH 33/60] feat: csrf tokens handling --- api/_build/openapi.yaml | 39 +++++++++++++++ api/api.gen.go | 72 +++++++++++++++++++++++++-- api/parameters/_index.yaml | 8 ++- api/parameters/access_token.yaml | 9 ++++ api/parameters/xsrf_token_cookie.yaml | 11 ++++ api/parameters/xsrf_token_header.yaml | 10 ++++ api/paths/titles-id.yaml | 2 + api/paths/users-id.yaml | 4 ++ api/schemas/JWTAuth.yaml | 7 +++ api/schemas/_index.yaml | 2 + modules/backend/main.go | 4 +- modules/backend/middlewares/csrf.go | 70 ++++++++++++++++++++++++++ 12 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 api/parameters/access_token.yaml create mode 100644 api/parameters/xsrf_token_cookie.yaml create mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/schemas/JWTAuth.yaml create mode 100644 modules/backend/middlewares/csrf.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e85ddf9..58dd890 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 @@ -156,6 +158,8 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: + - $ref: '#/components/parameters/accessToken' + - $ref: '#/components/parameters/csrfToken' - name: user_id in: path description: User ID (primary key) @@ -223,6 +227,8 @@ paths: description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -474,6 +480,39 @@ paths: description: Internal server error components: parameters: + accessToken: + name: access_token + in: cookie + required: true + schema: + type: string + format: jwt + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y + description: | + JWT access token. + csrfToken: + name: XSRF-TOKEN + in: cookie + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + example: abc123def456ghi789jkl012mno345pqr + description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + csrfTokenHeader: + name: X-XSRF-TOKEN + in: header + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). + example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor diff --git a/api/api.gen.go b/api/api.gen.go index c8fd9aa..62450e0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,10 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +const ( + JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -170,6 +174,12 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string +// AccessToken defines model for accessToken. +type AccessToken = string + +// CsrfToken defines model for csrfToken. +type CsrfToken = string + // Cursor defines model for cursor. type Cursor = string @@ -219,6 +229,17 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// UpdateUserParams defines parameters for UpdateUser. +type UpdateUserParams struct { + // AccessToken JWT access token. + AccessToken AccessToken `form:"access_token" json:"access_token"` + + // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). + // Stored in non-HttpOnly cookie, readable by JavaScript. + // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` +} + // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -276,7 +297,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64) + UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -431,6 +452,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 +524,47 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateUserParams + + { + var cookie string + + if cookie, err = c.Cookie("access_token"); err == nil { + var value AccessToken + err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) + return + } + params.AccessToken = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) + return + } + } + + { + var cookie string + + if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { + var value CsrfToken + err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) + return + } + params.XSRFTOKEN = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) + return + } + } + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -508,7 +572,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId) + siw.Handler.UpdateUser(c, userId, params) } // GetUserTitles operation middleware @@ -935,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` + Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1411,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { var request UpdateUserRequestObject request.UserId = userId + request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml index 6249e7d..d2e12a8 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,4 +1,10 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" +accessToken: + $ref: "./access_token.yaml" +csrfToken: + $ref: "./xsrf_token_cookie.yaml" +csrfTokenHeader: + $ref: "./xsrf_token_header.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml new file mode 100644 index 0000000..a7e727e --- /dev/null +++ b/api/parameters/access_token.yaml @@ -0,0 +1,9 @@ +name: access_token +in: cookie +required: true +schema: + type: string + format: jwt +example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" +description: | + JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml new file mode 100644 index 0000000..cf85999 --- /dev/null +++ b/api/parameters/xsrf_token_cookie.yaml @@ -0,0 +1,11 @@ +name: XSRF-TOKEN +in: cookie +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +example: "abc123def456ghi789jkl012mno345pqr" +description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml new file mode 100644 index 0000000..ac14dc1 --- /dev/null +++ b/api/parameters/xsrf_token_header.yaml @@ -0,0 +1,10 @@ +name: X-XSRF-TOKEN +in: header +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). +example: "abc123def456ghi789jkl012mno345pqr" \ 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.yaml b/api/paths/users-id.yaml index fe62e46..0f2f367 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -28,12 +28,16 @@ get: patch: summary: Partially update a user account + security: + - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser parameters: + - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc + - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF - 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/modules/backend/main.go b/modules/backend/main.go index 9f992a5..aab1287 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" @@ -45,6 +46,8 @@ func main() { r := gin.Default() + r.Use(middleware.CSRFMiddleware()) + // jwt middle will be here queries := sqlc.New(pool) // === RabbitMQ setup === @@ -63,7 +66,6 @@ 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 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 +} From ef871833c585e15fcba5e69a5e97bccb53e42eeb Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 34/60] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,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..6fee512 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(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ 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(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) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, 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,8 +133,9 @@ 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{ UserId: user.ID, 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..ef9b977 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,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,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"` } From b79a6b9117e4a7384398541105c801e81e0351d2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 35/60] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,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..6fee512 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(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ 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(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) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, 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,8 +133,9 @@ 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{ UserId: user.ID, 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..ef9b977 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,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,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"` } From 7629f391ad0889af10eb44a359c8bd0a1aa6a6e3 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 06:42:08 +0300 Subject: [PATCH 36/60] fix --- api/parameters/xsrf_token_cookie.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: From 1bbfa338d92b4122a658bb3487c98666aae4652a Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:17:31 +0300 Subject: [PATCH 37/60] feat: send xsrf_token header --- api/_build/openapi.yaml | 15 ++++-- api/parameters/xsrf_token_cookie.yaml | 2 +- api/paths/users-id-titles-id.yaml | 8 +++ api/paths/users-id.yaml | 5 +- auth/openapi-auth.yaml | 4 +- modules/frontend/package-lock.json | 53 ++++++++++++++++++- modules/frontend/package.json | 1 + modules/frontend/src/api/index.ts | 3 ++ .../frontend/src/api/models/accessToken.ts | 9 ++++ modules/frontend/src/api/models/csrfToken.ts | 11 ++++ .../src/api/models/csrfTokenHeader.ts | 10 ++++ .../src/api/services/DefaultService.ts | 21 ++++++++ .../frontend/src/auth/services/AuthService.ts | 17 +++--- .../TitleStatusControls.tsx | 9 +++- .../src/pages/LoginPage/LoginPage.tsx | 10 ++-- 15 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 modules/frontend/src/api/models/accessToken.ts create mode 100644 modules/frontend/src/api/models/csrfToken.ts create mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 58dd890..225e7cd 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,6 +150,8 @@ paths: description: User not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -158,8 +160,7 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/accessToken' - - $ref: '#/components/parameters/csrfToken' + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -404,11 +405,14 @@ paths: description: User or title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -450,11 +454,14 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -478,6 +485,8 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] components: parameters: accessToken: @@ -491,7 +500,7 @@ components: description: | JWT access token. csrfToken: - name: XSRF-TOKEN + name: xsrf_token in: cookie required: true schema: diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b4ad884..b56d07a 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user title operationId: getUserTitle + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -34,7 +36,10 @@ patch: summary: Update a usertitle description: User updating title list of watched operationId: updateUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -81,7 +86,10 @@ delete: summary: Delete a usertitle description: User deleting title from list of watched operationId: deleteUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0f2f367..abb170e 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user info operationId: getUsersId + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -36,8 +38,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser parameters: - - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc - - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF + - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true 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/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/api/index.ts b/modules/frontend/src/api/index.ts index 9013fc7..c1e9cdc 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,6 +7,9 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { accessToken } from './models/accessToken'; +export type { csrfToken } from './models/csrfToken'; +export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts new file mode 100644 index 0000000..adc8fb7 --- /dev/null +++ b/modules/frontend/src/api/models/accessToken.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * JWT access token. + * + */ +export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts new file mode 100644 index 0000000..4af805b --- /dev/null +++ b/modules/frontend/src/api/models/csrfToken.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token (Double Submit Cookie pattern). + * Stored in non-HttpOnly cookie, readable by JavaScript. + * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts new file mode 100644 index 0000000..354c8a3 --- /dev/null +++ b/modules/frontend/src/api/models/csrfTokenHeader.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 6898c46..f3d803d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,12 +135,16 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( + xXsrfToken: string, userId: number, requestBody: { /** @@ -171,6 +175,9 @@ export class DefaultService { path: { 'user_id': userId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -309,6 +316,9 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @param requestBody @@ -316,6 +326,7 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( + xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -330,6 +341,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -344,12 +358,16 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( + xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise<any> { @@ -360,6 +378,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, 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..4fb535a 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<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -41,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(userId, titleId); + await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); setCurrentStatus(null); return; } @@ -56,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { 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) { From b03f9c9704d93e596b55a474ba3656f9ba8e61b9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:20:10 +0300 Subject: [PATCH 38/60] fix: regen oapi for auth --- auth/auth.gen.go | 108 +++++++++++++++--------------- modules/auth/handlers/handlers.go | 12 ++-- 2 files changed, 60 insertions(+), 60 deletions(-) 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/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 6fee512..09907bc 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken 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) @@ -94,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) @@ -120,7 +120,7 @@ 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 } @@ -137,7 +137,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque 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, } From 6786f7ac00741960ef886b6f352ea36811fd9084 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:32:45 +0300 Subject: [PATCH 39/60] feat: access token check --- modules/backend/main.go | 35 ++++----- modules/backend/middlewares/access.go | 109 ++++++++++++++++++++++++++ modules/backend/types.go | 14 ++-- 3 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 modules/backend/middlewares/access.go diff --git a/modules/backend/main.go b/modules/backend/main.go index aab1287..0cffdcf 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -25,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) @@ -47,16 +47,11 @@ func main() { r := gin.Default() r.Use(middleware.CSRFMiddleware()) - // jwt middle will be here + 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) } @@ -68,7 +63,7 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -78,7 +73,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/types.go b/modules/backend/types.go index 20d3158..c4f70ed 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"` } From 066c44d08a13a5127340e9b116615e6786d3495d Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:35:49 +0300 Subject: [PATCH 40/60] fix: AllowOrigins --- modules/auth/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/auth/main.go b/modules/auth/main.go index ef9b977..7305b7d 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -44,8 +44,9 @@ func main() { server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) + log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From 570be2a68b0fb246e5f7ce86745223b1a0da7924 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:40:21 +0300 Subject: [PATCH 41/60] fix --- deploy/docker-compose.yml | 1 + modules/backend/main.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0ae97c6..1bd7f71 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -64,6 +64,7 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: diff --git a/modules/backend/main.go b/modules/backend/main.go index 0cffdcf..9dac2a6 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,7 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From b6cf5231369035e40f6e32023f2eede6fd6f886b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 07:43:37 +0300 Subject: [PATCH 42/60] fix --- deploy/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1bd7f71..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: @@ -64,7 +67,6 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: From e12dff3455c25c067df42af384ea9a6e82e393df Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 07:59:32 +0300 Subject: [PATCH 43/60] fix: cicd env fix --- .forgejo/workflows/build-and-deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 85a3c3ef107f9cbc4a80bef13861df559f8f2695 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:11:51 +0300 Subject: [PATCH 44/60] fix: backend config --- modules/backend/main.go | 2 +- modules/backend/types.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 9dac2a6..37dcc7b 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -51,7 +51,7 @@ func main() { queries := sqlc.New(pool) - rmqConn, err := amqp091.Dial(AppConfig.rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.RmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } diff --git a/modules/backend/types.go b/modules/backend/types.go index c4f70ed..a069307 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -6,5 +6,5 @@ type Config struct { 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"` + RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } From 79a716cf550a96d3a9851932116c9d8358972fef Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:27:22 +0300 Subject: [PATCH 45/60] fix: use []byte for jwt key --- modules/auth/handlers/handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..03df151 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -53,7 +53,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } @@ -63,7 +63,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(s.JwtPrivateKey) + refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } From 3be58457aa82ab7c2017ed42dd526636f8a870b3 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 08:44:26 +0300 Subject: [PATCH 46/60] fix(front): CookiesProvider --- modules/frontend/src/main.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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( <StrictMode> - <App /> + <CookiesProvider> + <App /> + </CookiesProvider> </StrictMode>, ) From 2f4f8164df2ed625c3e13f7a35ea3d17e47b2956 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 09:03:51 +0300 Subject: [PATCH 47/60] feat: CORS X-XSRF-TOKEN --- modules/backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 37dcc7b..24325eb 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -66,7 +66,7 @@ func main() { // 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, From 475266eef6fd08b6448475ae77e3631aab836efc Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 09:04:37 +0300 Subject: [PATCH 48/60] fix: revert AllowOrigins --- modules/backend/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 24325eb..b833cf9 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,8 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - // AllowOrigins: []string{AppConfig.ServiceAddress}, - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, From bd868bb724a7374f649779e5d48650155755f8c2 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 10:12:05 +0300 Subject: [PATCH 49/60] fix: reworked csrf --- api/_build/openapi.yaml | 54 ++++--------------- api/openapi.yaml | 2 + api/parameters/_index.yaml | 8 +-- api/parameters/access_token.yaml | 9 ---- api/parameters/xsrf_token_cookie.yaml | 11 ---- api/parameters/xsrf_token_header.yaml | 10 ---- api/paths/users-id-titles-id.yaml | 8 +-- api/paths/users-id.yaml | 8 ++- api/securitySchemes/_index.yaml | 11 ++++ modules/frontend/src/App.tsx | 4 ++ modules/frontend/src/api/index.ts | 3 -- .../frontend/src/api/models/accessToken.ts | 9 ---- modules/frontend/src/api/models/csrfToken.ts | 11 ---- .../src/api/models/csrfTokenHeader.ts | 10 ---- .../src/api/services/DefaultService.ts | 21 -------- .../TitleStatusControls.tsx | 10 ++-- 16 files changed, 39 insertions(+), 150 deletions(-) delete mode 100644 api/parameters/access_token.yaml delete mode 100644 api/parameters/xsrf_token_cookie.yaml delete mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/securitySchemes/_index.yaml delete mode 100644 modules/frontend/src/api/models/accessToken.ts delete mode 100644 modules/frontend/src/api/models/csrfToken.ts delete mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 225e7cd..3cbb361 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,8 +150,6 @@ paths: description: User not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -160,7 +158,6 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -229,7 +226,7 @@ paths: '500': description: Unknown server error security: - - JwtAuthCookies: [] + XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -405,14 +402,11 @@ paths: description: User or title not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -455,13 +449,12 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -486,42 +479,9 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] components: parameters: - accessToken: - name: access_token - in: cookie - required: true - schema: - type: string - format: jwt - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y - description: | - JWT access token. - csrfToken: - name: xsrf_token - in: cookie - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - example: abc123def456ghi789jkl012mno345pqr - description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - csrfTokenHeader: - name: X-XSRF-TOKEN - in: header - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). - example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor @@ -780,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/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/parameters/_index.yaml b/api/parameters/_index.yaml index d2e12a8..6249e7d 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,10 +1,4 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" -accessToken: - $ref: "./access_token.yaml" -csrfToken: - $ref: "./xsrf_token_cookie.yaml" -csrfTokenHeader: - $ref: "./xsrf_token_header.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml deleted file mode 100644 index a7e727e..0000000 --- a/api/parameters/access_token.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: access_token -in: cookie -required: true -schema: - type: string - format: jwt -example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" -description: | - JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml deleted file mode 100644 index 37041e0..0000000 --- a/api/parameters/xsrf_token_cookie.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: xsrf_token -in: cookie -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -example: "abc123def456ghi789jkl012mno345pqr" -description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml deleted file mode 100644 index ac14dc1..0000000 --- a/api/parameters/xsrf_token_header.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: X-XSRF-TOKEN -in: header -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). -example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b56d07a..1da2b81 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user title operationId: getUserTitle - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -37,9 +35,8 @@ patch: description: User updating title list of watched operationId: updateUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -87,9 +84,8 @@ delete: description: User deleting title from list of watched operationId: deleteUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index abb170e..5e9e69d 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user info operationId: getUsersId - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -30,15 +28,15 @@ get: patch: summary: Partially update a user account - security: - - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). 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' + # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true 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/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 95b59e3..5ff2b32 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,6 +6,10 @@ 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 + const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index c1e9cdc..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,9 +7,6 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; -export type { accessToken } from './models/accessToken'; -export type { csrfToken } from './models/csrfToken'; -export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts deleted file mode 100644 index adc8fb7..0000000 --- a/modules/frontend/src/api/models/accessToken.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * JWT access token. - * - */ -export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts deleted file mode 100644 index 4af805b..0000000 --- a/modules/frontend/src/api/models/csrfToken.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token (Double Submit Cookie pattern). - * Stored in non-HttpOnly cookie, readable by JavaScript. - * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts deleted file mode 100644 index 354c8a3..0000000 --- a/modules/frontend/src/api/models/csrfTokenHeader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index f3d803d..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,16 +135,12 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( - xXsrfToken: string, userId: number, requestBody: { /** @@ -175,9 +171,6 @@ export class DefaultService { path: { 'user_id': userId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -316,9 +309,6 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @param requestBody @@ -326,7 +316,6 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( - xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -341,9 +330,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -358,16 +344,12 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( - xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise<any> { @@ -378,9 +360,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 4fb535a..cc9f80d 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; -import { useCookies } from 'react-cookie'; +// import { useCookies } from 'react-cookie'; import { ClockIcon, @@ -19,8 +19,8 @@ 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 [cookies] = useCookies(['xsrf_token']); + // const xsrfToken = cookies['xsrf_token'] || null; const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -46,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); + await DefaultService.deleteUserTitle(userId, titleId); setCurrentStatus(null); return; } @@ -61,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { From 128a33824a2bb6d4b6a9a9e3168f8770e8e420c6 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 10:18:37 +0300 Subject: [PATCH 50/60] feat: regenerated go oapi --- api/_build/openapi.yaml | 2 +- api/api.gen.go | 71 ++++++----------------------------------- api/paths/users-id.yaml | 2 +- 3 files changed, 11 insertions(+), 64 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 3cbb361..e096beb 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -226,7 +226,7 @@ paths: '500': description: Unknown server error security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles diff --git a/api/api.gen.go b/api/api.gen.go index 62450e0..459a3e4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -18,6 +18,7 @@ import ( const ( JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" + XsrfAuthHeaderScopes = "XsrfAuthHeader.Scopes" ) // Defines values for ReleaseSeason. @@ -174,12 +175,6 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string -// AccessToken defines model for accessToken. -type AccessToken = string - -// CsrfToken defines model for csrfToken. -type CsrfToken = string - // Cursor defines model for cursor. type Cursor = string @@ -229,17 +224,6 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// UpdateUserParams defines parameters for UpdateUser. -type UpdateUserParams struct { - // AccessToken JWT access token. - AccessToken AccessToken `form:"access_token" json:"access_token"` - - // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). - // Stored in non-HttpOnly cookie, readable by JavaScript. - // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` -} - // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -297,7 +281,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) + UpdateUser(c *gin.Context, userId int64) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -524,46 +508,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } - c.Set(JwtAuthCookiesScopes, []string{}) - - // Parameter object where we will unmarshal all parameters from the context - var params UpdateUserParams - - { - var cookie string - - if cookie, err = c.Cookie("access_token"); err == nil { - var value AccessToken - err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) - return - } - params.AccessToken = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) - return - } - } - - { - var cookie string - - if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { - var value CsrfToken - err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) - return - } - params.XSRFTOKEN = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) - return - } - } + c.Set(XsrfAuthHeaderScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -572,7 +517,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId, params) + siw.Handler.UpdateUser(c, userId) } // GetUserTitles operation middleware @@ -745,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() { @@ -811,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() { @@ -999,7 +948,6 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` - Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1476,11 +1424,10 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { var request UpdateUserRequestObject request.UserId = userId - request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 5e9e69d..701df6b 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -34,7 +34,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] parameters: # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id From 6e802d2402756998fcc09eab1eb4882bafb4f372 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 11:30:35 +0300 Subject: [PATCH 51/60] feat!(front): migrate to Hey API --- modules/frontend/src/App.tsx | 4 +- modules/frontend/src/api/client.gen.ts | 16 + modules/frontend/src/api/client/client.gen.ts | 301 +++++++++ modules/frontend/src/api/client/index.ts | 25 + modules/frontend/src/api/client/types.gen.ts | 241 ++++++++ modules/frontend/src/api/client/utils.gen.ts | 332 ++++++++++ modules/frontend/src/api/core/ApiError.ts | 25 - .../src/api/core/ApiRequestOptions.ts | 17 - modules/frontend/src/api/core/ApiResult.ts | 11 - .../src/api/core/CancelablePromise.ts | 131 ---- modules/frontend/src/api/core/OpenAPI.ts | 32 - modules/frontend/src/api/core/auth.gen.ts | 42 ++ .../src/api/core/bodySerializer.gen.ts | 100 +++ modules/frontend/src/api/core/params.gen.ts | 176 ++++++ .../src/api/core/pathSerializer.gen.ts | 181 ++++++ .../src/api/core/queryKeySerializer.gen.ts | 136 +++++ modules/frontend/src/api/core/request.ts | 323 ---------- .../src/api/core/serverSentEvents.gen.ts | 264 ++++++++ modules/frontend/src/api/core/types.gen.ts | 118 ++++ modules/frontend/src/api/core/utils.gen.ts | 143 +++++ modules/frontend/src/api/index.ts | 30 +- modules/frontend/src/api/models/CursorObj.ts | 9 - modules/frontend/src/api/models/Image.ts | 11 - .../frontend/src/api/models/ReleaseSeason.ts | 8 - modules/frontend/src/api/models/Review.ts | 5 - .../frontend/src/api/models/StorageType.ts | 8 - modules/frontend/src/api/models/Studio.ts | 12 - modules/frontend/src/api/models/Tag.ts | 8 - modules/frontend/src/api/models/Tags.ts | 9 - modules/frontend/src/api/models/Title.ts | 31 - modules/frontend/src/api/models/TitleSort.ts | 8 - .../frontend/src/api/models/TitleStatus.ts | 8 - modules/frontend/src/api/models/User.ts | 33 - modules/frontend/src/api/models/UserTitle.ts | 15 - .../frontend/src/api/models/UserTitleMini.ts | 14 - .../src/api/models/UserTitleStatus.ts | 8 - modules/frontend/src/api/models/cursor.ts | 5 - modules/frontend/src/api/models/title_sort.ts | 6 - modules/frontend/src/api/sdk.gen.ts | 110 ++++ .../src/api/services/DefaultService.ts | 371 ------------ modules/frontend/src/api/types.gen.ts | 570 ++++++++++++++++++ .../TitleStatusControls.tsx | 48 +- .../components/cards/TitleCardHorizontal.tsx | 2 +- .../src/components/cards/TitleCardSquare.tsx | 3 +- .../src/pages/TitlePage/TitlePage.tsx | 9 +- .../src/pages/TitlesPage/TitlesPage.tsx | 56 +- .../frontend/src/pages/UserPage/UserPage.tsx | 59 +- 47 files changed, 2865 insertions(+), 1209 deletions(-) create mode 100644 modules/frontend/src/api/client.gen.ts create mode 100644 modules/frontend/src/api/client/client.gen.ts create mode 100644 modules/frontend/src/api/client/index.ts create mode 100644 modules/frontend/src/api/client/types.gen.ts create mode 100644 modules/frontend/src/api/client/utils.gen.ts delete mode 100644 modules/frontend/src/api/core/ApiError.ts delete mode 100644 modules/frontend/src/api/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/api/core/ApiResult.ts delete mode 100644 modules/frontend/src/api/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/api/core/OpenAPI.ts create mode 100644 modules/frontend/src/api/core/auth.gen.ts create mode 100644 modules/frontend/src/api/core/bodySerializer.gen.ts create mode 100644 modules/frontend/src/api/core/params.gen.ts create mode 100644 modules/frontend/src/api/core/pathSerializer.gen.ts create mode 100644 modules/frontend/src/api/core/queryKeySerializer.gen.ts delete mode 100644 modules/frontend/src/api/core/request.ts create mode 100644 modules/frontend/src/api/core/serverSentEvents.gen.ts create mode 100644 modules/frontend/src/api/core/types.gen.ts create mode 100644 modules/frontend/src/api/core/utils.gen.ts delete mode 100644 modules/frontend/src/api/models/CursorObj.ts delete mode 100644 modules/frontend/src/api/models/Image.ts delete mode 100644 modules/frontend/src/api/models/ReleaseSeason.ts delete mode 100644 modules/frontend/src/api/models/Review.ts delete mode 100644 modules/frontend/src/api/models/StorageType.ts delete mode 100644 modules/frontend/src/api/models/Studio.ts delete mode 100644 modules/frontend/src/api/models/Tag.ts delete mode 100644 modules/frontend/src/api/models/Tags.ts delete mode 100644 modules/frontend/src/api/models/Title.ts delete mode 100644 modules/frontend/src/api/models/TitleSort.ts delete mode 100644 modules/frontend/src/api/models/TitleStatus.ts delete mode 100644 modules/frontend/src/api/models/User.ts delete mode 100644 modules/frontend/src/api/models/UserTitle.ts delete mode 100644 modules/frontend/src/api/models/UserTitleMini.ts delete mode 100644 modules/frontend/src/api/models/UserTitleStatus.ts delete mode 100644 modules/frontend/src/api/models/cursor.ts delete mode 100644 modules/frontend/src/api/models/title_sort.ts create mode 100644 modules/frontend/src/api/sdk.gen.ts delete mode 100644 modules/frontend/src/api/services/DefaultService.ts create mode 100644 modules/frontend/src/api/types.gen.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5ff2b32..84c9086 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,9 +6,9 @@ import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; -import { OpenAPI } from "./api"; +// import { OpenAPI } from "./api"; -OpenAPI.WITH_CREDENTIALS = true +// OpenAPI.WITH_CREDENTIALS = true const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts new file mode 100644 index 0000000..952c663 --- /dev/null +++ b/modules/frontend/src/api/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; + +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/api/v1' })); diff --git a/modules/frontend/src/api/client/client.gen.ts b/modules/frontend/src/api/client/client.gen.ts new file mode 100644 index 0000000..c2a5190 --- /dev/null +++ b/modules/frontend/src/api/client/client.gen.ts @@ -0,0 +1,301 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { + body?: any; + headers: ReturnType<typeof mergeHeaders>; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn( + error, + undefined as any, + request, + opts, + )) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase<HttpMethod>) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record<string, string>, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/modules/frontend/src/api/client/index.ts b/modules/frontend/src/api/client/index.ts new file mode 100644 index 0000000..b295ede --- /dev/null +++ b/modules/frontend/src/api/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/modules/frontend/src/api/client/types.gen.ts b/modules/frontend/src/api/client/types.gen.ts new file mode 100644 index 0000000..b4a499c --- /dev/null +++ b/modules/frontend/src/api/client/types.gen.ts @@ -0,0 +1,241 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config<T extends ClientOptions = ClientOptions> + extends Omit<RequestInit, 'body' | 'headers' | 'method'>, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions<TData>, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray<Auth>; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record<string, unknown> + ? TData[keyof TData] + : TData + : { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record<string, unknown> + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record<string, unknown> + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record<string, unknown> + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, +) => Promise<ServerSentEventsResult<TData, TError>>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> & + Pick< + Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, + 'method' + >, +) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + url: string; + }, +>( + options: TData & Options<TData>, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( + override?: Config<ClientOptions & T>, +) => Config<Required<ClientOptions> & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions<TResponse, TResponseStyle, ThrowOnError>, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit<TData, 'url'>); diff --git a/modules/frontend/src/api/client/utils.gen.ts b/modules/frontend/src/api/client/utils.gen.ts new file mode 100644 index 0000000..4c48a9e --- /dev/null +++ b/modules/frontend/src/api/client/utils.gen.ts @@ -0,0 +1,332 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = <T = unknown>({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record<string, unknown>, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude<Config['parseAs'], 'auto'> => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick<Required<RequestOptions>, 'security'> & + Pick<RequestOptions, 'auth' | 'query'> & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array<Required<Config>['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor<Err, Res, Req, Options> = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise<Err>; + +type ReqInterceptor<Req, Options> = ( + request: Req, + options: Options, +) => Req | Promise<Req>; + +type ResInterceptor<Res, Req, Options> = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise<Res>; + +class Interceptors<Interceptor> { + fns: Array<Interceptor | null> = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware<Req, Res, Err, Options> { + error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; + request: Interceptors<ReqInterceptor<Req, Options>>; + response: Interceptors<ResInterceptor<Res, Req, Options>>; +} + +export const createInterceptors = <Req, Res, Err, Options>(): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), + request: new Interceptors<ReqInterceptor<Req, Options>>(), + response: new Interceptors<ResInterceptor<Res, Req, Options>>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = <T extends ClientOptions = ClientOptions>( + override: Config<Omit<ClientOptions, keyof T> & T> = {}, +): Config<Omit<ClientOptions, keyof T> & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/modules/frontend/src/api/core/ApiError.ts b/modules/frontend/src/api/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/api/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/modules/frontend/src/api/core/ApiRequestOptions.ts b/modules/frontend/src/api/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/api/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record<string, any>; - readonly cookies?: Record<string, any>; - readonly headers?: Record<string, any>; - readonly query?: Record<string, any>; - readonly formData?: Record<string, any>; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record<number, string>; -}; diff --git a/modules/frontend/src/api/core/ApiResult.ts b/modules/frontend/src/api/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/api/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/modules/frontend/src/api/core/CancelablePromise.ts b/modules/frontend/src/api/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/api/core/CancelablePromise.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise<T> implements Promise<T> { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise<T>; - #resolve?: (value: T | PromiseLike<T>) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike<T>) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise<T>((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike<T>): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then<TResult1 = T, TResult2 = never>( - onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null - ): Promise<TResult1 | TResult2> { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch<TResult = never>( - onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null - ): Promise<T | TResult> { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise<T> { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts deleted file mode 100644 index 185e5c3..0000000 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; -type Headers = Record<string, string>; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver<string> | undefined; - USERNAME?: string | Resolver<string> | undefined; - PASSWORD?: string | Resolver<string> | undefined; - HEADERS?: Headers | Resolver<Headers> | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/modules/frontend/src/api/core/auth.gen.ts b/modules/frontend/src/api/core/auth.gen.ts new file mode 100644 index 0000000..f8a7326 --- /dev/null +++ b/modules/frontend/src/api/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken, +): Promise<string | undefined> => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/modules/frontend/src/api/core/bodySerializer.gen.ts b/modules/frontend/src/api/core/bodySerializer.gen.ts new file mode 100644 index 0000000..552b50f --- /dev/null +++ b/modules/frontend/src/api/core/bodySerializer.gen.ts @@ -0,0 +1,100 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record<string, unknown>) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial<SerializerOptions<ArrayStyle>>; + object?: Partial<SerializerOptions<ObjectStyle>>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record<string, QuerySerializerOptionsObject>; +}; + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: <T>(body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/modules/frontend/src/api/core/params.gen.ts b/modules/frontend/src/api/core/params.gen.ts new file mode 100644 index 0000000..602715c --- /dev/null +++ b/modules/frontend/src/api/core/params.gen.ts @@ -0,0 +1,176 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude<Slot, 'body'>; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract<Slot, 'body'>; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial<Record<Slot, boolean>>; + args?: ReadonlyArray<Field>; +} + +export type FieldsConfig = ReadonlyArray<Field | Fields>; + +const extraPrefixesMap: Record<string, Slot> = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record<string, unknown>; + path: Record<string, unknown>; + query: Record<string, unknown>; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray<unknown>, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record<string, unknown>)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record<string, unknown>)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record<string, unknown>)[ + key.slice(prefix.length) + ] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record<string, unknown>)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/modules/frontend/src/api/core/pathSerializer.gen.ts b/modules/frontend/src/api/core/pathSerializer.gen.ts new file mode 100644 index 0000000..8d99931 --- /dev/null +++ b/modules/frontend/src/api/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions<T> + extends SerializePrimitiveOptions, + SerializerOptions<T> {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions<T> { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions<ArraySeparatorStyle> & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions<ObjectSeparatorStyle> & { + value: Record<string, unknown> | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/modules/frontend/src/api/core/queryKeySerializer.gen.ts b/modules/frontend/src/api/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d3bb683 --- /dev/null +++ b/modules/frontend/src/api/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record<string, unknown> => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record<string, JsonValue> = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/modules/frontend/src/api/core/request.ts b/modules/frontend/src/api/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/api/core/request.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record<string, any>): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; - -export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { - if (typeof resolver === 'function') { - return (resolver as Resolver<T>)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise<Record<string, string>> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) - .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), {} as Record<string, string>); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async <T>( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record<string, string>, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise<AxiosResponse<T>> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError<T>; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse<any>, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse<any>): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record<number, string> = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise<T> - * @throws ApiError - */ -export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise<T> => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/modules/frontend/src/api/core/serverSentEvents.gen.ts b/modules/frontend/src/api/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..f8fd78e --- /dev/null +++ b/modules/frontend/src/api/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions<TData = unknown> = Omit< + RequestInit, + 'method' +> & + Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise<Request>; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent<TData>) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise<void>; + url: string; + }; + +export interface StreamEvent<TData = unknown> { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record<string, unknown> ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = <TData = unknown>({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult<TData> => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record<string, string> | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array<string> = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/modules/frontend/src/api/core/types.gen.ts b/modules/frontend/src/api/core/types.gen.ts new file mode 100644 index 0000000..643c070 --- /dev/null +++ b/modules/frontend/src/api/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase<HttpMethod>; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise<unknown>; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise<unknown>; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise<unknown>; +} + +type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever<T extends Record<string, unknown>> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true + ? never + : K]: T[K]; +}; diff --git a/modules/frontend/src/api/core/utils.gen.ts b/modules/frontend/src/api/core/utils.gen.ts new file mode 100644 index 0000000..0b5389d --- /dev/null +++ b/modules/frontend/src/api/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record<string, unknown>; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record<string, unknown>, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record<string, unknown>; + query?: Record<string, unknown>; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 9013fc7..c352c10 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -1,28 +1,4 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; +// This file is auto-generated by @hey-api/openapi-ts -export type { cursor } from './models/cursor'; -export type { CursorObj } from './models/CursorObj'; -export type { Image } from './models/Image'; -export type { ReleaseSeason } from './models/ReleaseSeason'; -export type { Review } from './models/Review'; -export type { StorageType } from './models/StorageType'; -export type { Studio } from './models/Studio'; -export type { Tag } from './models/Tag'; -export type { Tags } from './models/Tags'; -export type { Title } from './models/Title'; -export type { title_sort } from './models/title_sort'; -export type { TitleSort } from './models/TitleSort'; -export type { TitleStatus } from './models/TitleStatus'; -export type { User } from './models/User'; -export type { UserTitle } from './models/UserTitle'; -export type { UserTitleMini } from './models/UserTitleMini'; -export type { UserTitleStatus } from './models/UserTitleStatus'; - -export { DefaultService } from './services/DefaultService'; +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/modules/frontend/src/api/models/CursorObj.ts b/modules/frontend/src/api/models/CursorObj.ts deleted file mode 100644 index f54abb1..0000000 --- a/modules/frontend/src/api/models/CursorObj.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type CursorObj = { - id: number; - param?: string; -}; - diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts deleted file mode 100644 index 887bf2f..0000000 --- a/modules/frontend/src/api/models/Image.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { StorageType } from './StorageType'; -export type Image = { - id?: number; - storage_type?: StorageType; - image_path?: string; -}; - diff --git a/modules/frontend/src/api/models/ReleaseSeason.ts b/modules/frontend/src/api/models/ReleaseSeason.ts deleted file mode 100644 index ad9f930..0000000 --- a/modules/frontend/src/api/models/ReleaseSeason.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title release season - */ -export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; diff --git a/modules/frontend/src/api/models/Review.ts b/modules/frontend/src/api/models/Review.ts deleted file mode 100644 index 9b453b7..0000000 --- a/modules/frontend/src/api/models/Review.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Review = Record<string, any>; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts deleted file mode 100644 index f6d086b..0000000 --- a/modules/frontend/src/api/models/StorageType.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Image storage type - */ -export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Studio.ts b/modules/frontend/src/api/models/Studio.ts deleted file mode 100644 index 062695a..0000000 --- a/modules/frontend/src/api/models/Studio.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type Studio = { - id: number; - name: string; - poster?: Image; - description?: string; -}; - diff --git a/modules/frontend/src/api/models/Tag.ts b/modules/frontend/src/api/models/Tag.ts deleted file mode 100644 index 665c724..0000000 --- a/modules/frontend/src/api/models/Tag.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * A localized tag: keys are language codes (ISO 639-1), values are tag names - */ -export type Tag = Record<string, string>; diff --git a/modules/frontend/src/api/models/Tags.ts b/modules/frontend/src/api/models/Tags.ts deleted file mode 100644 index 748f066..0000000 --- a/modules/frontend/src/api/models/Tags.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Tag } from './Tag'; -/** - * Array of localized tags - */ -export type Tags = Array<Tag>; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts deleted file mode 100644 index 9ffdeb6..0000000 --- a/modules/frontend/src/api/models/Title.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -import type { ReleaseSeason } from './ReleaseSeason'; -import type { Studio } from './Studio'; -import type { Tags } from './Tags'; -import type { TitleStatus } from './TitleStatus'; -export type Title = { - /** - * Unique title ID (primary key) - */ - id: number; - /** - * Localized titles. Key = language (ISO 639-1), value = list of names - */ - title_names: Record<string, Array<string>>; - studio?: Studio; - tags: Tags; - poster?: Image; - title_status?: TitleStatus; - rating?: number; - rating_count?: number; - release_year?: number; - release_season?: ReleaseSeason; - episodes_aired?: number; - episodes_all?: number; - episodes_len?: Record<string, number>; -}; - diff --git a/modules/frontend/src/api/models/TitleSort.ts b/modules/frontend/src/api/models/TitleSort.ts deleted file mode 100644 index 1c9385e..0000000 --- a/modules/frontend/src/api/models/TitleSort.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title sort order - */ -export type TitleSort = 'id' | 'year' | 'rating' | 'views'; diff --git a/modules/frontend/src/api/models/TitleStatus.ts b/modules/frontend/src/api/models/TitleStatus.ts deleted file mode 100644 index 72e0261..0000000 --- a/modules/frontend/src/api/models/TitleStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Title status - */ -export type TitleStatus = 'finished' | 'ongoing' | 'planned'; diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts deleted file mode 100644 index 969023f..0000000 --- a/modules/frontend/src/api/models/User.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Image } from './Image'; -export type User = { - /** - * Unique user ID (primary key) - */ - id?: number; - image?: Image; - /** - * User email - */ - mail?: string; - /** - * Username (alphanumeric + _ or -) - */ - nickname: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description - */ - user_desc?: string; - /** - * Timestamp when the user was created - */ - creation_date?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts deleted file mode 100644 index 42b7919..0000000 --- a/modules/frontend/src/api/models/UserTitle.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Title } from './Title'; -import type { UserTitleStatus } from './UserTitleStatus'; -export type UserTitle = { - user_id: number; - title?: Title; - status: UserTitleStatus; - rate?: number; - review_id?: number; - ctime?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts deleted file mode 100644 index 2b223ce..0000000 --- a/modules/frontend/src/api/models/UserTitleMini.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { UserTitleStatus } from './UserTitleStatus'; -export type UserTitleMini = { - user_id: number; - title_id: number; - status: UserTitleStatus; - rate?: number; - review_id?: number; - ctime?: string; -}; - diff --git a/modules/frontend/src/api/models/UserTitleStatus.ts b/modules/frontend/src/api/models/UserTitleStatus.ts deleted file mode 100644 index 0a29626..0000000 --- a/modules/frontend/src/api/models/UserTitleStatus.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * User's title status - */ -export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; diff --git a/modules/frontend/src/api/models/cursor.ts b/modules/frontend/src/api/models/cursor.ts deleted file mode 100644 index 5788e14..0000000 --- a/modules/frontend/src/api/models/cursor.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type cursor = string; diff --git a/modules/frontend/src/api/models/title_sort.ts b/modules/frontend/src/api/models/title_sort.ts deleted file mode 100644 index 69b01a7..0000000 --- a/modules/frontend/src/api/models/title_sort.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { TitleSort } from './TitleSort'; -export type title_sort = TitleSort; diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts new file mode 100644 index 0000000..5359156 --- /dev/null +++ b/modules/frontend/src/api/sdk.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; + +export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record<string, unknown>; +}; + +/** + * Get titles + */ +export const getTitles = <ThrowOnError extends boolean = false>(options?: Options<GetTitlesData, ThrowOnError>) => (options?.client ?? client).get<GetTitlesResponses, GetTitlesErrors, ThrowOnError>({ + querySerializer: { parameters: { status: { array: { explode: false } } } }, + url: '/titles', + ...options +}); + +/** + * Get title description + */ +export const getTitle = <ThrowOnError extends boolean = false>(options: Options<GetTitleData, ThrowOnError>) => (options.client ?? client).get<GetTitleResponses, GetTitleErrors, ThrowOnError>({ url: '/titles/{title_id}', ...options }); + +/** + * Get user info + */ +export const getUsersId = <ThrowOnError extends boolean = false>(options: Options<GetUsersIdData, ThrowOnError>) => (options.client ?? client).get<GetUsersIdResponses, GetUsersIdErrors, ThrowOnError>({ url: '/users/{user_id}', ...options }); + +/** + * Partially update a user account + * + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + */ +export const updateUser = <ThrowOnError extends boolean = false>(options: Options<UpdateUserData, ThrowOnError>) => (options.client ?? client).patch<UpdateUserResponses, UpdateUserErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get user titles + */ +export const getUserTitles = <ThrowOnError extends boolean = false>(options: Options<GetUserTitlesData, ThrowOnError>) => (options.client ?? client).get<GetUserTitlesResponses, GetUserTitlesErrors, ThrowOnError>({ + querySerializer: { parameters: { status: { array: { explode: false } }, watch_status: { array: { explode: false } } } }, + url: '/users/{user_id}/titles', + ...options +}); + +/** + * Add a title to a user + * + * User adding title to list af watched, status required + */ +export const addUserTitle = <ThrowOnError extends boolean = false>(options: Options<AddUserTitleData, ThrowOnError>) => (options.client ?? client).post<AddUserTitleResponses, AddUserTitleErrors, ThrowOnError>({ + url: '/users/{user_id}/titles', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Delete a usertitle + * + * User deleting title from list of watched + */ +export const deleteUserTitle = <ThrowOnError extends boolean = false>(options: Options<DeleteUserTitleData, ThrowOnError>) => (options.client ?? client).delete<DeleteUserTitleResponses, DeleteUserTitleErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}/titles/{title_id}', + ...options +}); + +/** + * Get user title + */ +export const getUserTitle = <ThrowOnError extends boolean = false>(options: Options<GetUserTitleData, ThrowOnError>) => (options.client ?? client).get<GetUserTitleResponses, GetUserTitleErrors, ThrowOnError>({ url: '/users/{user_id}/titles/{title_id}', ...options }); + +/** + * Update a usertitle + * + * User updating title list of watched + */ +export const updateUserTitle = <ThrowOnError extends boolean = false>(options: Options<UpdateUserTitleData, ThrowOnError>) => (options.client ?? client).patch<UpdateUserTitleResponses, UpdateUserTitleErrors, ThrowOnError>({ + security: [{ name: 'X-XSRF-TOKEN', type: 'apiKey' }], + url: '/users/{user_id}/titles/{title_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts deleted file mode 100644 index 6898c46..0000000 --- a/modules/frontend/src/api/services/DefaultService.ts +++ /dev/null @@ -1,371 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CursorObj } from '../models/CursorObj'; -import type { ReleaseSeason } from '../models/ReleaseSeason'; -import type { Title } from '../models/Title'; -import type { TitleSort } from '../models/TitleSort'; -import type { TitleStatus } from '../models/TitleStatus'; -import type { User } from '../models/User'; -import type { UserTitle } from '../models/UserTitle'; -import type { UserTitleMini } from '../models/UserTitleMini'; -import type { UserTitleStatus } from '../models/UserTitleStatus'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Get titles - * @param cursor - * @param sort - * @param sortForward - * @param extSearch - * @param word - * @param status List of title statuses to filter - * @param rating - * @param releaseYear - * @param releaseSeason - * @param limit - * @param offset - * @param fields - * @returns any List of titles with cursor - * @throws ApiError - */ - public static getTitles( - cursor?: string, - sort?: TitleSort, - sortForward: boolean = true, - extSearch: boolean = false, - word?: string, - status?: Array<TitleStatus>, - rating?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - offset?: number, - fields: string = 'all', - ): CancelablePromise<{ - /** - * List of titles - */ - data: Array<Title>; - cursor: CursorObj; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles', - query: { - 'cursor': cursor, - 'sort': sort, - 'sort_forward': sortForward, - 'ext_search': extSearch, - 'word': word, - 'status': status, - 'rating': rating, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'offset': offset, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get title description - * @param titleId - * @param fields - * @returns Title Title description - * @throws ApiError - */ - public static getTitle( - titleId: number, - fields: string = 'all', - ): CancelablePromise<Title> { - return __request(OpenAPI, { - method: 'GET', - url: '/titles/{title_id}', - path: { - 'title_id': titleId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `Title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user info - * @param userId - * @param fields - * @returns User User info - * @throws ApiError - */ - public static getUsersId( - userId: string, - fields: string = 'all', - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - query: { - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Partially update a user account - * Update selected user profile fields (excluding password). - * Password updates must be done via the dedicated auth-service (`/auth/`). - * Fields not provided in the request body remain unchanged. - * - * @param userId User ID (primary key) - * @param requestBody - * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). - * @throws ApiError - */ - public static updateUser( - userId: number, - requestBody: { - /** - * ID of the user avatar (references `images.id`); set to `null` to remove avatar - */ - avatar_id?: number | null; - /** - * User email (must be unique and valid) - */ - mail?: string; - /** - * Username (alphanumeric + `_` or `-`, 3–16 chars) - */ - nickname?: string; - /** - * Display name - */ - disp_name?: string; - /** - * User description / bio - */ - user_desc?: string; - }, - ): CancelablePromise<User> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, - 401: `Unauthorized — missing or invalid authentication token`, - 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, - 404: `User not found`, - 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, - 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, - 500: `Unknown server error`, - }, - }); - } - /** - * Get user titles - * @param userId - * @param cursor - * @param sort - * @param sortForward - * @param word - * @param status List of title statuses to filter - * @param watchStatus - * @param rating - * @param myRate - * @param releaseYear - * @param releaseSeason - * @param limit - * @param fields - * @returns any List of user titles - * @throws ApiError - */ - public static getUserTitles( - userId: string, - cursor?: string, - sort?: TitleSort, - sortForward: boolean = true, - word?: string, - status?: Array<TitleStatus>, - watchStatus?: Array<UserTitleStatus>, - rating?: number, - myRate?: number, - releaseYear?: number, - releaseSeason?: ReleaseSeason, - limit: number = 10, - fields: string = 'all', - ): CancelablePromise<{ - data: Array<UserTitle>; - cursor: CursorObj; - }> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - query: { - 'cursor': cursor, - 'sort': sort, - 'sort_forward': sortForward, - 'word': word, - 'status': status, - 'watch_status': watchStatus, - 'rating': rating, - 'my_rate': myRate, - 'release_year': releaseYear, - 'release_season': releaseSeason, - 'limit': limit, - 'fields': fields, - }, - errors: { - 400: `Request params are not correct`, - 404: `User not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Add a title to a user - * User adding title to list af watched, status required - * @param userId ID of the user to assign the title to - * @param requestBody - * @returns UserTitleMini Title successfully added to user - * @throws ApiError - */ - public static addUserTitle( - userId: number, - requestBody: { - title_id: number; - status: UserTitleStatus; - rate?: number; - }, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'POST', - url: '/users/{user_id}/titles', - path: { - 'user_id': userId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid request body (missing fields, invalid types, etc.)`, - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to assign titles to this user`, - 404: `User or Title not found`, - 409: `Conflict — title already assigned to user (if applicable)`, - 500: `Internal server error`, - }, - }); - } - /** - * Get user title - * @param userId - * @param titleId - * @returns UserTitleMini User titles - * @throws ApiError - */ - public static getUserTitle( - userId: number, - titleId: number, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'GET', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - errors: { - 400: `Request params are not correct`, - 404: `User or title not found`, - 500: `Unknown server error`, - }, - }); - } - /** - * Update a usertitle - * User updating title list of watched - * @param userId - * @param titleId - * @param requestBody - * @returns UserTitleMini Title successfully updated - * @throws ApiError - */ - public static updateUserTitle( - userId: number, - titleId: number, - requestBody: { - status?: UserTitleStatus; - rate?: number; - }, - ): CancelablePromise<UserTitleMini> { - return __request(OpenAPI, { - method: 'PATCH', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Invalid request body (missing fields, invalid types, etc.)`, - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to update title`, - 404: `User or Title not found`, - 500: `Internal server error`, - }, - }); - } - /** - * Delete a usertitle - * User deleting title from list of watched - * @param userId - * @param titleId - * @returns any Title successfully deleted - * @throws ApiError - */ - public static deleteUserTitle( - userId: number, - titleId: number, - ): CancelablePromise<any> { - return __request(OpenAPI, { - method: 'DELETE', - url: '/users/{user_id}/titles/{title_id}', - path: { - 'user_id': userId, - 'title_id': titleId, - }, - errors: { - 401: `Unauthorized — missing or invalid auth token`, - 403: `Forbidden — user not allowed to delete title`, - 404: `User or Title not found`, - 500: `Internal server error`, - }, - }); - } -} diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts new file mode 100644 index 0000000..ce4db4b --- /dev/null +++ b/modules/frontend/src/api/types.gen.ts @@ -0,0 +1,570 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/api/v1` | (string & {}); +}; + +/** + * Title sort order + */ +export type TitleSort = 'id' | 'year' | 'rating' | 'views'; + +/** + * Title status + */ +export type TitleStatus = 'finished' | 'ongoing' | 'planned'; + +/** + * Title release season + */ +export type ReleaseSeason = 'winter' | 'spring' | 'summer' | 'fall'; + +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; + +export type Image = { + id?: number; + storage_type?: StorageType; + image_path?: string; +}; + +export type Studio = { + id: number; + name: string; + poster?: Image; + description?: string; +}; + +/** + * A localized tag: keys are language codes (ISO 639-1), values are tag names + */ +export type Tag = { + [key: string]: string; +}; + +/** + * Array of localized tags + */ +export type Tags = Array<Tag>; + +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: { + [key: string]: Array<string>; + }; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: { + [key: string]: number; + }; +}; + +export type CursorObj = { + id: number; + param?: string; +}; + +export type User = { + /** + * Unique user ID (primary key) + */ + id?: number; + image?: Image; + /** + * User email + */ + mail?: string; + /** + * Username (alphanumeric + _ or -) + */ + nickname: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description + */ + user_desc?: string; + /** + * Timestamp when the user was created + */ + creation_date?: string; +}; + +/** + * User's title status + */ +export type UserTitleStatus = 'finished' | 'planned' | 'dropped' | 'in-progress'; + +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + +export type Review = { + [key: string]: unknown; +}; + +export type Cursor = string; + +export type TitleSort2 = TitleSort; + +export type GetTitlesData = { + body?: never; + path?: never; + query?: { + cursor?: string; + sort?: TitleSort; + sort_forward?: boolean; + ext_search?: boolean; + word?: string; + /** + * List of title statuses to filter + */ + status?: Array<TitleStatus>; + rating?: number; + release_year?: number; + release_season?: ReleaseSeason; + limit?: number; + offset?: number; + fields?: string; + }; + url: '/titles'; +}; + +export type GetTitlesErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetTitlesResponses = { + /** + * List of titles with cursor + */ + 200: { + /** + * List of titles + */ + data: Array<Title>; + cursor: CursorObj; + }; + /** + * No titles found + */ + 204: void; +}; + +export type GetTitlesResponse = GetTitlesResponses[keyof GetTitlesResponses]; + +export type GetTitleData = { + body?: never; + path: { + title_id: number; + }; + query?: { + fields?: string; + }; + url: '/titles/{title_id}'; +}; + +export type GetTitleErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Title not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetTitleResponses = { + /** + * Title description + */ + 200: Title; + /** + * No title found + */ + 204: void; +}; + +export type GetTitleResponse = GetTitleResponses[keyof GetTitleResponses]; + +export type GetUsersIdData = { + body?: never; + path: { + user_id: string; + }; + query?: { + fields?: string; + }; + url: '/users/{user_id}'; +}; + +export type GetUsersIdErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUsersIdResponses = { + /** + * User info + */ + 200: User; +}; + +export type GetUsersIdResponse = GetUsersIdResponses[keyof GetUsersIdResponses]; + +export type UpdateUserData = { + /** + * Only provided fields are updated. Omitted fields remain unchanged. + */ + body: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }; + path: { + /** + * User ID (primary key) + */ + user_id: number; + }; + query?: never; + url: '/users/{user_id}'; +}; + +export type UpdateUserErrors = { + /** + * Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid authentication token + */ + 401: unknown; + /** + * Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + */ + 403: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Conflict — e.g., requested `nickname` or `mail` already taken by another user + */ + 409: unknown; + /** + * Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + */ + 422: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type UpdateUserResponses = { + /** + * User updated successfully. Returns updated user representation (excluding sensitive fields). + */ + 200: User; +}; + +export type UpdateUserResponse = UpdateUserResponses[keyof UpdateUserResponses]; + +export type GetUserTitlesData = { + body?: never; + path: { + user_id: string; + }; + query?: { + cursor?: string; + sort?: TitleSort; + sort_forward?: boolean; + word?: string; + /** + * List of title statuses to filter + */ + status?: Array<TitleStatus>; + watch_status?: Array<UserTitleStatus>; + rating?: number; + my_rate?: number; + release_year?: number; + release_season?: ReleaseSeason; + limit?: number; + fields?: string; + }; + url: '/users/{user_id}/titles'; +}; + +export type GetUserTitlesErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUserTitlesResponses = { + /** + * List of user titles + */ + 200: { + data: Array<UserTitle>; + cursor: CursorObj; + }; + /** + * No titles found + */ + 204: void; +}; + +export type GetUserTitlesResponse = GetUserTitlesResponses[keyof GetUserTitlesResponses]; + +export type AddUserTitleData = { + body: { + title_id: number; + status: UserTitleStatus; + rate?: number; + }; + path: { + /** + * ID of the user to assign the title to + */ + user_id: number; + }; + query?: never; + url: '/users/{user_id}/titles'; +}; + +export type AddUserTitleErrors = { + /** + * Invalid request body (missing fields, invalid types, etc.) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to assign titles to this user + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Conflict — title already assigned to user (if applicable) + */ + 409: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type AddUserTitleResponses = { + /** + * Title successfully added to user + */ + 200: UserTitleMini; +}; + +export type AddUserTitleResponse = AddUserTitleResponses[keyof AddUserTitleResponses]; + +export type DeleteUserTitleData = { + body?: never; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type DeleteUserTitleErrors = { + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to delete title + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteUserTitleResponses = { + /** + * Title successfully deleted + */ + 200: unknown; +}; + +export type GetUserTitleData = { + body?: never; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type GetUserTitleErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * User or title not found + */ + 404: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUserTitleResponses = { + /** + * User titles + */ + 200: UserTitleMini; + /** + * No user title found + */ + 204: void; +}; + +export type GetUserTitleResponse = GetUserTitleResponses[keyof GetUserTitleResponses]; + +export type UpdateUserTitleData = { + body: { + status?: UserTitleStatus; + rate?: number; + }; + path: { + user_id: number; + title_id: number; + }; + query?: never; + url: '/users/{user_id}/titles/{title_id}'; +}; + +export type UpdateUserTitleErrors = { + /** + * Invalid request body (missing fields, invalid types, etc.) + */ + 400: unknown; + /** + * Unauthorized — missing or invalid auth token + */ + 401: unknown; + /** + * Forbidden — user not allowed to update title + */ + 403: unknown; + /** + * User or Title not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateUserTitleResponses = { + /** + * Title successfully updated + */ + 200: UserTitleMini; +}; + +export type UpdateUserTitleResponse = UpdateUserTitleResponses[keyof UpdateUserTitleResponses]; diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index cc9f80d..3cc16cf 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { DefaultService } from "../../api"; +// import { DefaultService } from "../../api"; +import { addUserTitle, deleteUserTitle, getUserTitle, updateUserTitle } from "../../api"; import type { UserTitleStatus } from "../../api"; -// import { useCookies } from 'react-cookie'; +import { useCookies } from 'react-cookie'; import { ClockIcon, @@ -9,6 +10,7 @@ import { PlayCircleIcon, XCircleIcon, } from "@heroicons/react/24/solid"; +// import { stat } from "fs"; // Статусы с иконками и подписью const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ @@ -19,8 +21,8 @@ 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 [cookies] = useCookies(['xsrf_token']); + const xsrfToken = cookies['xsrf_token'] || null; const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -31,10 +33,13 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { // --- Load initial status --- useEffect(() => { if (!userId) return; + getUserTitle({ path: { user_id: userId, title_id: titleId } }) + .then(res => setCurrentStatus(res.data?.status ?? null)) + .catch(() => setCurrentStatus(null)); // 404 = not assigned - DefaultService.getUserTitle(userId, titleId) - .then((res) => setCurrentStatus(res.status)) - .catch(() => setCurrentStatus(null)); // 404 = user title does not exist + // DefaultService.getUserTitle(userId, titleId) + // .then((res) => setCurrentStatus(res.status)) + // .catch(() => setCurrentStatus(null)); // 404 = user title does not exist }, [titleId, userId]); // --- Handle click --- @@ -46,7 +51,11 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(userId, titleId); + // await DefaultService.deleteUserTitle(userId, titleId); + await deleteUserTitle({path: { + user_id: userId, + title_id: titleId, + }}) setCurrentStatus(null); return; } @@ -54,15 +63,28 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { // 2) Если другой статус — POST или PATCH if (!currentStatus) { // ещё нет записи — POST - const added = await DefaultService.addUserTitle(userId, { + // const added = await DefaultService.addUserTitle(userId, { + // title_id: titleId, + // status, + // }); + const added = await addUserTitle({ + body: { title_id: titleId, - status, + status: status, + }, + path: {user_id: userId} }); - setCurrentStatus(added.status); + + setCurrentStatus(added.data?.status ?? null); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); - setCurrentStatus(updated.status); + //const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + const updated = await updateUserTitle({ + path: { user_id: userId, title_id: titleId }, + body: { status }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }); + setCurrentStatus(updated.data?.status ?? null); } } finally { setLoading(false); diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index cde6037..b848702 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -1,4 +1,4 @@ -import type { Title } from "../../api/models/Title"; +import type { Title } from "../../api"; export function TitleCardHorizontal({ title }: { title: Title }) { return ( diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index e21c258..0bcb49d 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -1,5 +1,4 @@ -// TitleCardSquare.tsx -import type { Title } from "../../api/models/Title"; +import type { Title } from "../../api"; export function TitleCardSquare({ title }: { title: Title }) { return ( diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 01f9c49..0d9e297 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; -import type { Title } from "../../api"; +// import { DefaultService } from "../../api/services/DefaultService"; +import { getTitle, type Title } from "../../api"; import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; export default function TitlePage() { @@ -19,8 +19,9 @@ export default function TitlePage() { const fetchTitle = async () => { setLoading(true); try { - const data = await DefaultService.getTitle(titleId, "all"); - setTitle(data); + // const data = await DefaultService.getTitle(titleId, "all"); + const data = await getTitle({path: {title_id: titleId}}) + setTitle(data?.data ?? null); setError(null); } catch (err: any) { console.error(err); diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index ed55d8d..481d116 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from "react"; import { ListView } from "../../components/ListView/ListView"; import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; -import { DefaultService } from "../../api/services/DefaultService"; +// import { DefaultService } from "../../api/services/DefaultService"; import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; -import type { CursorObj, Title, TitleSort } from "../../api"; +import { getTitles, type CursorObj, type Title, type TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; @@ -32,37 +32,31 @@ export default function TitlesPage() { }); const fetchPage = async (cursorObj: CursorObj | null) => { - const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : undefined; - try { - const result = await DefaultService.getTitles( - cursorStr, - sort, - sortForward, - filters.extSearch, - search.trim() || undefined, - filters.status ? [filters.status] : undefined, - filters.rating || undefined, - filters.releaseYear || undefined, - filters.releaseSeason || undefined, - PAGE_SIZE, - PAGE_SIZE, - "all" - ); + const response = await getTitles({ + query: { + cursor: cursorStr, + sort: sort, + sort_forward: sortForward, + ext_search: filters.extSearch, + word: search.trim() || undefined, + status: filters.status ? [filters.status] : undefined, + rating: filters.rating || undefined, + release_year: filters.releaseYear || undefined, + release_season: filters.releaseSeason || undefined, + limit: PAGE_SIZE, + offset: PAGE_SIZE, + fields: "all", + }, + }); - if ((result === undefined) || !result.data?.length) { - return { items: [], nextCursor: null }; - } - return { - items: result.data ?? [], - nextCursor: result.cursor ?? null - }; - } catch (err: any) { - if (err.status === 204) { - return { items: [], nextCursor: null }; - } - throw err; - } + return { + items: response.data?.data ?? [], + nextCursor: response.data?.cursor ?? null, + }; }; // Инициализация: загружаем сразу две страницы diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 7cc0db5..d9ff5f2 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,14 +1,14 @@ // pages/UserPage/UserPage.tsx import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; +// import { DefaultService } from "../../api/services/DefaultService"; import { SearchBar } from "../../components/SearchBar/SearchBar"; import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { ListView } from "../../components/ListView/ListView"; import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; -import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { type User, type UserTitle, type CursorObj, type TitleSort, getUsersId, getUserTitles } from "../../api"; import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -42,8 +42,9 @@ export default function UserPage({ userId }: UserPageProps) { if (!id) return; setLoadingUser(true); try { - const result = await DefaultService.getUsersId(id, "all"); - setUser(result); + // const result = await DefaultService.getUsersId(id, "all"); + const result = await getUsersId({path: {user_id: id}}) + setUser(result?.data ?? null); setErrorUser(null); } catch (err: any) { console.error(err); @@ -63,25 +64,41 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUserTitles( - id, - cursorStr, - sort, - sortForward, - search.trim() || undefined, - undefined, // status фильтр, можно добавить - undefined, // watchStatus - undefined, // rating - undefined, // myRate - undefined, // releaseYear - undefined, // releaseSeason - PAGE_SIZE, - "all" - ); + const result = await getUserTitles({ + path: { + user_id: id, + }, + query: { + cursor: cursorStr, + sort: sort, + sort_forward: sortForward, + word: search.trim() || undefined, + status: undefined, + watch_status: undefined, + rating: undefined, + my_rate: undefined, + release_year: undefined, + release_season: undefined, + limit: PAGE_SIZE}}) + // const result = await DefaultService.getUserTitles( + // id, + // cursorStr, + // sort, + // sortForward, + // search.trim() || undefined, + // undefined, // status фильтр, можно добавить + // undefined, // watchStatus + // undefined, // rating + // undefined, // myRate + // undefined, // releaseYear + // undefined, // releaseSeason + // PAGE_SIZE, + // "all" + // ); - if (!result?.data?.length) return { items: [], nextCursor: null }; + if (!result?.data?.data.length) return { items: [], nextCursor: null }; - return { items: result.data, nextCursor: result.cursor ?? null }; + return { items: result.data?.data, nextCursor: result.data?.cursor ?? null }; } catch (err: any) { if (err.status === 204) return { items: [], nextCursor: null }; throw err; From fc2fa6b9786808f0164c0d6f7c8c7bb92b545675 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 11:52:18 +0300 Subject: [PATCH 52/60] feat: oapi credenials include --- modules/frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5ff2b32..67336c1 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -9,6 +9,7 @@ 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; From 1ec5b2f09cc21383aaa5f190802464047763843b Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 12:34:56 +0300 Subject: [PATCH 53/60] debug: csrf cookie --- modules/frontend/src/api/client.gen.ts | 2 +- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../src/components/TitleStatusControls/TitleStatusControls.tsx | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts index 952c663..2de06ac 100644 --- a/modules/frontend/src/api/client.gen.ts +++ b/modules/frontend/src/api/client.gen.ts @@ -13,4 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; -export const client = createClient(createConfig<ClientOptions2>({ baseUrl: '/api/v1' })); +export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://10.1.0.65:8081/api/v1' })); diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 2d0edf8..79aa305 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/auth', + BASE: 'http://10.1.0.65:8081/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 3cc16cf..0566fbf 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -23,6 +23,7 @@ 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; + console.log("xsrf_token: " + xsrfToken) const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); From 5d1d138aca61784f9d2411ae4db4687f78d7ced9 Mon Sep 17 00:00:00 2001 From: nihonium <nihonium@nekoea.red> Date: Thu, 4 Dec 2025 13:01:10 +0300 Subject: [PATCH 54/60] fix: minor fixes for the frontend --- modules/auth/handlers/handlers.go | 2 +- .../TitleStatusControls/TitleStatusControls.tsx | 8 +++++--- modules/frontend/src/pages/UserPage/UserPage.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 03df151..ac55abe 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -135,7 +135,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject ginCtx.SetSameSite(http.SameSiteStrictMode) 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) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/", "", false, false) result := auth.PostSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 0566fbf..98fa868 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -23,7 +23,6 @@ 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; - console.log("xsrf_token: " + xsrfToken) const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [loading, setLoading] = useState(false); @@ -56,7 +55,9 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { await deleteUserTitle({path: { user_id: userId, title_id: titleId, - }}) + }, + headers: { "X-XSRF-TOKEN": xsrfToken }, + }) setCurrentStatus(null); return; } @@ -73,7 +74,8 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { title_id: titleId, status: status, }, - path: {user_id: userId} + path: {user_id: userId}, + headers: { "X-XSRF-TOKEN": xsrfToken }, }); setCurrentStatus(added.data?.status ?? null); diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index d9ff5f2..1a8ba1e 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -96,7 +96,7 @@ export default function UserPage({ userId }: UserPageProps) { // "all" // ); - if (!result?.data?.data.length) return { items: [], nextCursor: null }; + if (!result?.data?.data?.length) return { items: [], nextCursor: null }; return { items: result.data?.data, nextCursor: result.data?.cursor ?? null }; } catch (err: any) { From 604ac0ebbccd35b0081337f985e21abefa700307 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Thu, 4 Dec 2025 20:12:54 +0300 Subject: [PATCH 55/60] feat: now auth could be disabled with pipeline param --- .forgejo/workflows/build-and-deploy.yml | 1 + deploy/docker-compose.yml | 1 + modules/backend/main.go | 6 ++++-- modules/backend/types.go | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index dde9392..b82fb3d 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -116,6 +116,7 @@ jobs: JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} RABBITMQ_DEFAULT_USER: ${{ secrets.RABBITMQ_USER }} RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_PASSWORD }} + AUTH_ENABLED: ${{ vars.AUTH_ENABLED }} steps: - name: Checkout code diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 82116eb..1119335 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -50,6 +50,7 @@ services: SERVICE_ADDRESS: ${SERVICE_ADDRESS} RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + AUTH_ENABLED: ${AUTH_ENABLED} ports: - "8080:8080" depends_on: diff --git a/modules/backend/main.go b/modules/backend/main.go index b833cf9..755e3ef 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -46,8 +46,10 @@ func main() { r := gin.Default() - r.Use(middleware.CSRFMiddleware()) - r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + if len(AppConfig.AuthEnabled) > 0 && AppConfig.AuthEnabled != "false" { + r.Use(middleware.CSRFMiddleware()) + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + } queries := sqlc.New(pool) diff --git a/modules/backend/types.go b/modules/backend/types.go index a069307..ceaec4e 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -7,4 +7,5 @@ type Config struct { JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` + AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` } From 169bb482ce522ddac00ae5c841f6f6c618d38f51 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 19:42:25 +0300 Subject: [PATCH 56/60] feat: desc field for title was added --- sql/migrations/000001_init.up.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..d6353d6 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -47,6 +47,8 @@ CREATE TABLE titles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, + -- example {"ru": "Кулинарное аниме как правильно приготовить людей.","en": "A culinary anime about how to cook people properly."} + title_desc jsonb, studio_id bigint NOT NULL REFERENCES studios (id), poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, From 40e341c05ad2f5fea246c82afa40717450d2ebf6 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:13:16 +0300 Subject: [PATCH 57/60] feat: query SearchUsers was written --- sql/models.go | 1 + sql/queries.sql.go | 85 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/sql/models.go b/sql/models.go index 842d58c..b1ea282 100644 --- a/sql/models.go +++ b/sql/models.go @@ -246,6 +246,7 @@ type Tag struct { type Title struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..0c17599 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -129,7 +129,7 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one SELECT - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + t.id, t.title_names, t.title_desc, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -157,6 +157,7 @@ GROUP BY type GetTitleByIDRow struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -185,6 +186,7 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByID err := row.Scan( &i.ID, &i.TitleNames, + &i.TitleDesc, &i.StudioID, &i.PosterID, &i.TitleStatus, @@ -638,6 +640,87 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S return items, nil } +const searchUser = `-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + $1::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim($1::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + $2::int IS NULL + OR u.id > $2::int + ) +ORDER BY u.id ASC +LIMIT COALESCE($3::int, 20) +` + +type SearchUserParams struct { + Word *string `json:"word"` + Cursor *int32 `json:"cursor"` + Limit *int32 `json:"limit"` +} + +type SearchUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` +} + +func (q *Queries) SearchUser(ctx context.Context, arg SearchUserParams) ([]SearchUserRow, error) { + rows, err := q.db.Query(ctx, searchUser, arg.Word, arg.Cursor, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchUserRow{} + for rows.Next() { + var i SearchUserRow + if err := rows.Scan( + &i.ID, + &i.AvatarID, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.StorageType, + &i.ImagePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchUserTitles = `-- name: SearchUserTitles :many SELECT From fe18c0d865c4d27e27ad7be4d79ce328693c6850 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:14:08 +0300 Subject: [PATCH 58/60] feat /users path is specified --- api/_build/openapi.yaml | 47 ++++++++++++++ api/api.gen.go | 131 ++++++++++++++++++++++++++++++++++++++++ api/openapi.yaml | 2 + api/paths/users.yaml | 46 ++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 api/paths/users.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e096beb..7f483fa 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -122,6 +122,53 @@ paths: description: Unknown server error security: - JwtAuthCookies: [] + /users/: + get: + summary: 'Search user by nickname or dispname (both in one param), response is always sorted by id' + parameters: + - name: word + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + default: 10 + - name: cursor_id + in: query + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + description: List of users + type: array + items: + $ref: '#/components/schemas/User' + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error '/users/{user_id}': get: operationId: getUsersId diff --git a/api/api.gen.go b/api/api.gen.go index 459a3e4..4fa16f4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -201,6 +201,15 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + Word *string `form:"word,omitempty" json:"word,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // CursorId pass cursor naked + CursorId *int32 `form:"cursor_id,omitempty" json:"cursor_id,omitempty"` +} + // GetUsersIdParams defines parameters for GetUsersId. type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` @@ -276,6 +285,9 @@ type ServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(c *gin.Context, titleId int64, params GetTitleParams) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(c *gin.Context, params GetUsersParams) // Get user info // (GET /users/{user_id}) GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) @@ -459,6 +471,48 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, titleId, params) } +// GetUsers operation middleware +func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersParams + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "cursor_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor_id", c.Request.URL.Query(), ¶ms.CursorId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsers(c, params) +} + // GetUsersId operation middleware func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { @@ -799,6 +853,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/", wrapper.GetUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) @@ -904,6 +959,52 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetUsersRequestObject struct { + Params GetUsersParams +} + +type GetUsersResponseObject interface { + VisitGetUsersResponse(w http.ResponseWriter) error +} + +type GetUsers200JSONResponse struct { + Cursor int64 `json:"cursor"` + + // Data List of users + Data []User `json:"data"` +} + +func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsers204Response struct { +} + +func (response GetUsers204Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsers400Response struct { +} + +func (response GetUsers400Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsers500Response struct { +} + +func (response GetUsers500Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersIdParams @@ -1305,6 +1406,9 @@ type StrictServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) @@ -1395,6 +1499,33 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTit } } +// GetUsers operation middleware +func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { + var request GetUsersRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersResponseObject); ok { + if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersId operation middleware func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { var request GetUsersIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index d84797f..0759a54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,6 +11,8 @@ paths: $ref: "./paths/titles.yaml" /titles/{title_id}: $ref: "./paths/titles-id.yaml" + /users/: + $ref: "./paths/users.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: diff --git a/api/paths/users.yaml b/api/paths/users.yaml new file mode 100644 index 0000000..14fb0c0 --- /dev/null +++ b/api/paths/users.yaml @@ -0,0 +1,46 @@ +get: + summary: Search user by nickname or dispname (both in one param), response is always sorted by id + parameters: + - in: query + name: word + schema: + type: string + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: cursor_id + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/User.yaml' + description: List of users + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error From 6a5994e33e5f840d7e3b646d7ed7266f1a4d9cc9 Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 20:15:05 +0300 Subject: [PATCH 59/60] feat: handler for get /users is implemented --- modules/backend/handlers/users.go | 36 +++++++++++++++++++++++++++++++ modules/backend/queries.sql | 31 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d6faade..995d5af 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -485,3 +485,39 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil } + +// GetUsers implements oapi.StrictServerInterface. +func (s *Server) GetUsers(ctx context.Context, request oapi.GetUsersRequestObject) (oapi.GetUsersResponseObject, error) { + params := sqlc.SearchUserParams{ + Word: request.Params.Word, + Cursor: request.Params.CursorId, + Limit: request.Params.Limit, + } + _users, err := s.db.SearchUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsers500Response{}, nil + } + if len(_users) == 0 { + return oapi.GetUsers204Response{}, nil + } + + var users []oapi.User + var cursor int64 + for _, user := range _users { + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // add image + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + users = append(users, oapi_user) + + cursor = user.ID + } + + return oapi.GetUsers200JSONResponse{Data: users, Cursor: cursor}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ff41cb1..03502c4 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -23,6 +23,37 @@ FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) WHERE t.id = sqlc.arg('id')::bigint; +-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + sqlc.narg('word')::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim(sqlc.narg('word')::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + sqlc.narg('cursor')::int IS NULL + OR u.id > sqlc.narg('cursor')::int + ) +ORDER BY u.id ASC +LIMIT COALESCE(sqlc.narg('limit')::int, 20); -- name: GetStudioByID :one SELECT * From 62e0633e69a5bd4b658847155bb808beb34b821b Mon Sep 17 00:00:00 2001 From: Iron_Felix <trubnikov.arseniy@mail.ru> Date: Fri, 5 Dec 2025 21:20:51 +0300 Subject: [PATCH 60/60] fix: rmq --- modules/backend/handlers/common.go | 22 +-- modules/backend/handlers/titles.go | 1 - modules/backend/main.go | 3 +- modules/backend/rmq/rabbit.go | 214 ++++++----------------------- 4 files changed, 53 insertions(+), 187 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index cad4f0f..58862e1 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -9,24 +9,24 @@ import ( "strconv" ) -type Handler struct { - publisher *rmq.Publisher -} +// type Handler struct { +// publisher *rmq.Publisher +// } -func New(publisher *rmq.Publisher) *Handler { - return &Handler{publisher: publisher} -} +// func New(publisher *rmq.Publisher) *Handler { +// return &Handler{publisher: publisher} +// } type Server struct { - db *sqlc.Queries - publisher *rmq.Publisher + db *sqlc.Queries + // publisher *rmq.Publisher RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { +func NewServer(db *sqlc.Queries, rpcclient *rmq.RPCClient) *Server { return &Server{ - db: db, - publisher: publisher, + db: db, + // publisher: publisher, RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 300cc87..7aeeb11 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -197,7 +197,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // Делаем RPC-вызов — и ЖДЁМ ответа err := s.RPCclient.Call( ctx, - "svc.media.process.requests", // ← очередь микросервиса mqreq, &reply, ) diff --git a/modules/backend/main.go b/modules/backend/main.go index 755e3ef..e7e6ec8 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -59,10 +59,9 @@ func main() { } defer rmqConn.Close() - publisher := rmq.NewPublisher(rmqConn) rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, publisher, rpcClient) + server := handlers.NewServer(queries, rpcClient) r.Use(cors.New(cors.Config{ AllowOrigins: []string{AppConfig.ServiceAddress}, diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go index 52c1979..25abbdb 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "fmt" - oapi "nyanimedb/api" - "sync" "time" + oapi "nyanimedb/api" + amqp "github.com/rabbitmq/amqp091-go" ) +const RPCQueueName = "anime_import_rpc" + +// RabbitRequest не меняем type RabbitRequest struct { Name string `json:"name"` Statuses []oapi.TitleStatus `json:"statuses,omitempty"` @@ -20,151 +23,6 @@ type RabbitRequest struct { Timestamp time.Time `json:"timestamp"` } -// Publisher — потокобезопасный публикатор с пулом каналов. -type Publisher struct { - conn *amqp.Connection - pool *sync.Pool -} - -// NewPublisher создаёт новый Publisher. -// conn должен быть уже установленным и healthy. -// Рекомендуется передавать durable connection с reconnect-логикой. -func NewPublisher(conn *amqp.Connection) *Publisher { - return &Publisher{ - conn: conn, - pool: &sync.Pool{ - New: func() any { - ch, err := conn.Channel() - if err != nil { - // Паника уместна: невозможность открыть канал — критическая ошибка инициализации - panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) - } - return ch - }, - }, - } -} - -// Publish публикует сообщение в указанную очередь. -// Очередь объявляется как durable (если не существует). -// Поддерживает context для отмены/таймаута. -func (p *Publisher) Publish( - ctx context.Context, - queueName string, - payload RabbitRequest, - opts ...PublishOption, -) error { - // Применяем опции - options := &publishOptions{ - contentType: "application/json", - deliveryMode: amqp.Persistent, - timestamp: time.Now(), - } - for _, opt := range opts { - opt(options) - } - - // Сериализуем payload - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) - } - - // Берём канал из пула - ch := p.getChannel() - if ch == nil { - return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") - } - defer p.returnChannel(ch) - - // Объявляем очередь (idempotent) - q, err := ch.QueueDeclare( - queueName, - true, // durable - false, // auto-delete - false, // exclusive - false, // no-wait - nil, // args - ) - if err != nil { - return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) - } - - // Подготавливаем сообщение - msg := amqp.Publishing{ - DeliveryMode: options.deliveryMode, - ContentType: options.contentType, - Timestamp: options.timestamp, - Body: body, - } - - // Публикуем с учётом контекста - done := make(chan error, 1) - go func() { - err := ch.Publish( - "", // exchange (default) - q.Name, // routing key - false, // mandatory - false, // immediate - msg, - ) - done <- err - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (p *Publisher) getChannel() *amqp.Channel { - raw := p.pool.Get() - if raw == nil { - ch, _ := p.conn.Channel() - return ch - } - ch := raw.(*amqp.Channel) - if ch.IsClosed() { // ← теперь есть! - ch.Close() // освободить ресурсы - ch, _ = p.conn.Channel() - } - return ch -} - -// returnChannel возвращает канал в пул, если он жив. -func (p *Publisher) returnChannel(ch *amqp.Channel) { - if ch != nil && !ch.IsClosed() { - p.pool.Put(ch) - } -} - -// PublishOption позволяет кастомизировать публикацию. -type PublishOption func(*publishOptions) - -type publishOptions struct { - contentType string - deliveryMode uint8 - timestamp time.Time -} - -// WithContentType устанавливает Content-Type (по умолчанию "application/json"). -func WithContentType(ct string) PublishOption { - return func(o *publishOptions) { o.contentType = ct } -} - -// WithTransient делает сообщение transient (не сохраняется на диск). -// По умолчанию — Persistent. -func WithTransient() PublishOption { - return func(o *publishOptions) { o.deliveryMode = amqp.Transient } -} - -// WithTimestamp устанавливает кастомную метку времени. -func WithTimestamp(ts time.Time) PublishOption { - return func(o *publishOptions) { o.timestamp = ts } -} - type RPCClient struct { conn *amqp.Connection timeout time.Duration @@ -174,37 +32,48 @@ func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { return &RPCClient{conn: conn, timeout: timeout} } -// Call отправляет запрос в очередь и ждёт ответа. -// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). func (c *RPCClient) Call( ctx context.Context, - requestQueue string, request RabbitRequest, replyPayload any, ) error { - // 1. Создаём временный канал (не из пула!) + + // 1. Канал для запроса и ответа ch, err := c.conn.Channel() if err != nil { return fmt.Errorf("channel: %w", err) } defer ch.Close() - // 2. Создаём временную очередь для ответов - q, err := ch.QueueDeclare( - "", // auto name - false, // not durable - true, // exclusive - true, // auto-delete + // 2. Декларируем фиксированную очередь RPC (идемпотентно) + _, err = ch.QueueDeclare( + RPCQueueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, + ) + if err != nil { + return fmt.Errorf("declare rpc queue: %w", err) + } + + // 3. Создаём временную очередь ДЛЯ ОТВЕТА + replyQueue, err := ch.QueueDeclare( + "", + false, + true, + true, false, nil, ) if err != nil { - return fmt.Errorf("reply queue: %w", err) + return fmt.Errorf("declare reply queue: %w", err) } - // 3. Подписываемся на ответы + // 4. Подписываемся на очередь ответов msgs, err := ch.Consume( - q.Name, + replyQueue.Name, "", true, // auto-ack true, // exclusive @@ -213,28 +82,28 @@ func (c *RPCClient) Call( nil, ) if err != nil { - return fmt.Errorf("consume: %w", err) + return fmt.Errorf("consume reply: %w", err) } - // 4. Готовим correlation ID - corrID := time.Now().UnixNano() + // correlation ID + corrID := fmt.Sprintf("%d", time.Now().UnixNano()) - // 5. Сериализуем запрос + // 5. сериализация запроса body, err := json.Marshal(request) if err != nil { return fmt.Errorf("marshal request: %w", err) } - // 6. Публикуем запрос + // 6. Публикация RPC-запроса err = ch.Publish( "", - requestQueue, + RPCQueueName, // ← фиксированная очередь! false, false, amqp.Publishing{ ContentType: "application/json", - CorrelationId: fmt.Sprintf("%d", corrID), - ReplyTo: q.Name, + CorrelationId: corrID, + ReplyTo: replyQueue.Name, Timestamp: time.Now(), Body: body, }, @@ -244,18 +113,17 @@ func (c *RPCClient) Call( } // 7. Ждём ответ с таймаутом - ctx, cancel := context.WithTimeout(ctx, c.timeout) + timeoutCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() for { select { case msg := <-msgs: - if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + if msg.CorrelationId == corrID { return json.Unmarshal(msg.Body, replyPayload) } - // игнорируем другие сообщения (маловероятно, но возможно) - case <-ctx.Done(): - return ctx.Err() // timeout or cancelled + case <-timeoutCtx.Done(): + return fmt.Errorf("rpc timeout: %w", timeoutCtx.Err()) } } }