From 57956f1f6e0b8cd565584317fc14d76f6dc8c5de Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:36:05 +0300 Subject: [PATCH 01/35] feat: field description is added to Title --- api/_build/openapi.yaml | 5 +++++ api/api.gen.go | 3 +++ api/schemas/Title.yaml | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 7f483fa..5b6f731 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -647,6 +647,11 @@ components: example: - Attack on Titan - AoT + title_desc: + description: 'Localized description. Key = language (ISO 639-1), value = description.' + type: object + additionalProperties: + type: string studio: $ref: '#/components/schemas/Studio' tags: diff --git a/api/api.gen.go b/api/api.gen.go index 4fa16f4..ff37ed9 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -113,6 +113,9 @@ type Title struct { // Tags Array of localized tags Tags Tags `json:"tags"` + // TitleDesc Localized description. Key = language (ISO 639-1), value = description. + TitleDesc *map[string]string `json:"title_desc,omitempty"` + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names TitleNames map[string][]string `json:"title_names"` diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 877ee24..fac4a3f 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -30,6 +30,11 @@ properties: - Титаны ja: - 進撃の巨人 + title_desc: + type: object + description: Localized description. Key = language (ISO 639-1), value = description. + additionalProperties: + type: string studio: $ref: ./Studio.yaml tags: From 6d14ac365bbbb9d795a81b3c2028ab4fbd2c0eb4 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:37:13 +0300 Subject: [PATCH 02/35] feat: title desc handling --- modules/backend/handlers/common.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 58862e1..7f2807f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -73,6 +73,14 @@ func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { } oapi_title.TitleNames = title_names + if len(title.TitleDesc) > 0 { + title_descs := make(map[string]string, 0) + err = json.Unmarshal(title.TitleDesc, &title_descs) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleDesc: %v", err) + } + oapi_title.TitleDesc = &title_descs + } if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) From 9cb3f94e2700cfeee798f74b3b8eda672cee1fea Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:53:29 +0300 Subject: [PATCH 03/35] feat: gif fot waiting --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 481d116..449cb70 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -127,7 +127,16 @@ const handleLoadMore = async () => { - {loading &&
Loading...
} + {loading && ( +
+ Loading... + Loading animation +
+ )} {!loading && titles.length === 0 && (
No titles found.
From 90d7de51f3f81649fb5b713542d011db5f150968 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 00:04:51 +0300 Subject: [PATCH 04/35] fix: gif scaled --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 449cb70..75d8fa5 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -133,7 +133,7 @@ const handleLoadMore = async () => { Loading animation )} From 7623adf2a7a75d9100bc2d1d54f0f0c1ea5eedfd Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 00:27:59 +0300 Subject: [PATCH 05/35] fix --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 75d8fa5..727e072 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -131,9 +131,9 @@ const handleLoadMore = async () => {
Loading... Loading animation
)} From e67f0d7e5a89924d232098b75f51ba9c1c11477e Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 04:03:04 +0300 Subject: [PATCH 06/35] feat: get impersonation token implementation --- auth/auth.gen.go | 115 +++++++++++++++++++++++++-- auth/openapi-auth.yaml | 124 +++++++++++------------------- modules/auth/handlers/handlers.go | 38 ++++++++- modules/auth/queries.sql | 4 + sql/migrations/000001_init.up.sql | 5 +- sql/models.go | 5 +- sql/queries.sql.go | 13 ++++ 7 files changed, 209 insertions(+), 95 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b7cd839..89a2168 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,6 +13,23 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody struct { + TgId *int64 `json:"tg_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage +} + +// GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody0 = interface{} + +// GetImpersonationTokenJSONBody1 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody1 = interface{} + // PostSignInJSONBody defines parameters for PostSignIn. type PostSignInJSONBody struct { Nickname string `json:"nickname"` @@ -25,6 +42,9 @@ type PostSignUpJSONBody struct { Pass string `json:"pass"` } +// GetImpersonationTokenJSONRequestBody defines body for GetImpersonationToken for application/json ContentType. +type GetImpersonationTokenJSONRequestBody GetImpersonationTokenJSONBody + // PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. type PostSignInJSONRequestBody PostSignInJSONBody @@ -33,6 +53,9 @@ type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(c *gin.Context) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(c *gin.Context) @@ -50,6 +73,21 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetImpersonationToken operation middleware +func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { + + c.Set(BearerAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetImpersonationToken(c) +} + // PostSignIn operation middleware func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { @@ -103,10 +141,41 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } +type UnauthorizedErrorResponse struct { +} + +type GetImpersonationTokenRequestObject struct { + Body *GetImpersonationTokenJSONRequestBody +} + +type GetImpersonationTokenResponseObject interface { + VisitGetImpersonationTokenResponse(w http.ResponseWriter) error +} + +type GetImpersonationToken200JSONResponse struct { + // AccessToken JWT access token + AccessToken string `json:"access_token"` +} + +func (response GetImpersonationToken200JSONResponse) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetImpersonationToken401Response = UnauthorizedErrorResponse + +func (response GetImpersonationToken401Response) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + type PostSignInRequestObject struct { Body *PostSignInJSONRequestBody } @@ -127,15 +196,11 @@ func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.Respons return json.NewEncoder(w).Encode(response) } -type PostSignIn401JSONResponse struct { - Error *string `json:"error,omitempty"` -} +type PostSignIn401Response = UnauthorizedErrorResponse -func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") +func (response PostSignIn401Response) VisitPostSignInResponse(w http.ResponseWriter) error { w.WriteHeader(401) - - return json.NewEncoder(w).Encode(response) + return nil } type PostSignUpRequestObject struct { @@ -159,6 +224,9 @@ func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.Respons // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) @@ -179,6 +247,39 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetImpersonationToken operation middleware +func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { + var request GetImpersonationTokenRequestObject + + var body GetImpersonationTokenJSONRequestBody + 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.GetImpersonationToken(ctx, request.(GetImpersonationTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetImpersonationToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetImpersonationTokenResponseObject); ok { + if err := validResponse.VisitGetImpersonationTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostSignIn operation middleware func (sh *strictHandler) PostSignIn(ctx *gin.Context) { var request PostSignInRequestObject diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 5f3ebd6..93db937 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -10,6 +10,7 @@ paths: /sign-up: post: summary: Sign up a new user + operationId: postSignUp tags: [Auth] requestBody: required: true @@ -41,6 +42,7 @@ paths: /sign-in: post: summary: Sign in a user and return JWT + operationId: postSignIn tags: [Auth] requestBody: required: true @@ -73,88 +75,52 @@ paths: user_name: type: string "401": - description: Access denied due to invalid credentials + $ref: '#/components/responses/UnauthorizedError' + + /get-impersonation-token: + post: + summary: Get service impersontaion token + operationId: getImpersonationToken + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + format: int64 + tg_id: + type: integer + format: int64 + oneOf: + - required: ["user_id"] + - required: ["tg_id"] + responses: + "200": + description: Generated impersonation access token content: application/json: schema: type: object + required: + - access_token properties: - error: + access_token: type: string - 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 + description: JWT access token + "401": + $ref: '#/components/responses/UnauthorizedError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + responses: + UnauthorizedError: + description: Access token is missing or invalid \ No newline at end of file diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..39067a6 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -47,10 +47,28 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } +func (s Server) generateImpersonationToken(userID string, impersonated_by string) (accessToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + "imp_id": impersonated_by, + } + + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + + accessToken, err = at.SignedString(s.JwtPrivateKey) + if err != nil { + return "", err + } + + return accessToken, nil +} + 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(), + //TODO: add created_at } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = at.SignedString(s.JwtPrivateKey) @@ -119,10 +137,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject // TODO: return 500 } if !ok { - err_msg := "invalid credentials" - return auth.PostSignIn401JSONResponse{ - Error: &err_msg, - }, nil + return auth.PostSignIn401Response{}, nil } accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) @@ -144,6 +159,21 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } +func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + token := ginCtx.Request.Header.Get("Authorization") + log.Printf("got auth token: %s", token) + //s.db.GetExternalServiceByToken() + + return auth.PostSignIn401Response{}, nil +} + // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { // valid := false // var userID *string diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 828d2af..363f07a 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -9,3 +9,7 @@ INTO users (passhash, nickname) VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) RETURNING id; +-- name: GetExternalServiceByToken :one +SELECT * +FROM external_services +WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..cda8d71 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -33,8 +33,6 @@ CREATE TABLE users ( last_login timestamptz ); - - CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, @@ -106,7 +104,8 @@ CREATE TABLE signals ( CREATE TABLE external_services ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL + name text UNIQUE NOT NULL, + auth_token text ); CREATE TABLE external_ids ( diff --git a/sql/models.go b/sql/models.go index 842d58c..ee30f58 100644 --- a/sql/models.go +++ b/sql/models.go @@ -193,8 +193,9 @@ type ExternalID struct { } type ExternalService struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + AuthToken *string `json:"auth_token"` } type Image struct { diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..7fd8765 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -74,6 +74,19 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const getExternalServiceByToken = `-- name: GetExternalServiceByToken :one +SELECT id, name, auth_token +FROM external_services +WHERE auth_token = $1 +` + +func (q *Queries) GetExternalServiceByToken(ctx context.Context, authToken *string) (ExternalService, error) { + row := q.db.QueryRow(ctx, getExternalServiceByToken, authToken) + var i ExternalService + err := row.Scan(&i.ID, &i.Name, &i.AuthToken) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 184868b142376c800d82988ccf05950db810c9a8 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 04:13:27 +0300 Subject: [PATCH 07/35] feat: file upload imlemented --- api/_build/openapi.yaml | 36 ++++++++ api/api.gen.go | 109 ++++++++++++++++++++++ api/openapi.yaml | 4 +- api/paths/media_upload.yaml | 37 ++++++++ go.mod | 16 ++-- go.sum | 20 ++++ modules/backend/handlers/images.go | 141 +++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 api/paths/media_upload.yaml create mode 100644 modules/backend/handlers/images.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5b6f731..9ed5b5f 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -527,6 +527,42 @@ paths: description: Internal server error security: - XsrfAuthHeader: [] + /media/upload: + post: + summary: 'Upload an image (PNG, JPEG, or WebP)' + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + encoding: + image: + contentType: 'image/png, image/jpeg, image/webp' + multipart/form-data: + schema: + image: + type: string + format: binary + description: 'Image file (PNG, JPEG, or WebP)' + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Image' + '400': + description: 'Bad request — e.g., invalid/malformed image, empty file' + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + '500': + description: Internal server error components: parameters: cursor: diff --git a/api/api.gen.go b/api/api.gen.go index ff37ed9..d93e925 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -7,7 +7,10 @@ import ( "context" "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -181,6 +184,9 @@ type UserTitleStatus string // Cursor defines model for cursor. type Cursor = string +// PostMediaUploadMultipartBody defines parameters for PostMediaUpload. +type PostMediaUploadMultipartBody = interface{} + // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -271,6 +277,9 @@ type UpdateUserTitleJSONBody struct { Status *UserTitleStatus `json:"status,omitempty"` } +// PostMediaUploadMultipartRequestBody defines body for PostMediaUpload for multipart/form-data ContentType. +type PostMediaUploadMultipartRequestBody = PostMediaUploadMultipartBody + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody @@ -282,6 +291,9 @@ type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(c *gin.Context) // Get titles // (GET /titles) GetTitles(c *gin.Context, params GetTitlesParams) @@ -323,6 +335,19 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// PostMediaUpload operation middleware +func (siw *ServerInterfaceWrapper) PostMediaUpload(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostMediaUpload(c) +} + // GetTitles operation middleware func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { @@ -854,6 +879,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/media/upload", wrapper.PostMediaUpload) router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/", wrapper.GetUsers) @@ -866,6 +892,49 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } +type PostMediaUploadRequestObject struct { + Body io.Reader + MultipartBody *multipart.Reader +} + +type PostMediaUploadResponseObject interface { + VisitPostMediaUploadResponse(w http.ResponseWriter) error +} + +type PostMediaUpload200JSONResponse Image + +func (response PostMediaUpload200JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload400JSONResponse string + +func (response PostMediaUpload400JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload415Response struct { +} + +func (response PostMediaUpload415Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(415) + return nil +} + +type PostMediaUpload500Response struct { +} + +func (response PostMediaUpload500Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetTitlesRequestObject struct { Params GetTitlesParams } @@ -1403,6 +1472,9 @@ func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.R // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(ctx context.Context, request PostMediaUploadRequestObject) (PostMediaUploadResponseObject, error) // Get titles // (GET /titles) GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) @@ -1447,6 +1519,43 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// PostMediaUpload operation middleware +func (sh *strictHandler) PostMediaUpload(ctx *gin.Context) { + var request PostMediaUploadRequestObject + + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "encoding") { + request.Body = ctx.Request.Body + } + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "multipart/form-data") { + if reader, err := ctx.Request.MultipartReader(); err == nil { + request.MultipartBody = reader + } else { + ctx.Error(err) + return + } + } + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostMediaUpload(ctx, request.(PostMediaUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostMediaUpload") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostMediaUploadResponseObject); ok { + if err := validResponse.VisitPostMediaUploadResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetTitles operation middleware func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { var request GetTitlesRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 0759a54..26813fc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -19,7 +19,9 @@ paths: $ref: "./paths/users-id-titles.yaml" /users/{user_id}/titles/{title_id}: $ref: "./paths/users-id-titles-id.yaml" - + /media/upload: + $ref: "./paths/media_upload.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/media_upload.yaml b/api/paths/media_upload.yaml new file mode 100644 index 0000000..0453952 --- /dev/null +++ b/api/paths/media_upload.yaml @@ -0,0 +1,37 @@ +post: + summary: Upload an image (PNG, JPEG, or WebP) + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + multipart/form-data: + schema: + image: + type: string + format: binary + description: Image file (PNG, JPEG, or WebP) + encoding: + image: + contentType: image/png, image/jpeg, image/webp + + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: "../schemas/Image.yaml" + '400': + description: Bad request — e.g., invalid/malformed image, empty file + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + + '500': + description: Internal server error \ No newline at end of file diff --git a/go.mod b/go.mod index 6662bc1..08a3dc1 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -42,12 +43,13 @@ 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 - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.33.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/go.sum b/go.sum index 520a22b..dc41797 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -103,10 +105,18 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y 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/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= 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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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= @@ -114,11 +124,15 @@ 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/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -131,6 +145,8 @@ 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/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= @@ -144,12 +160,16 @@ 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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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= diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go new file mode 100644 index 0000000..5309480 --- /dev/null +++ b/modules/backend/handlers/images.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + oapi "nyanimedb/api" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + log "github.com/sirupsen/logrus" + "golang.org/x/image/webp" +) + +// PostMediaUpload implements oapi.StrictServerInterface. +func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) { + // Получаем multipart body + mp := request.MultipartBody + if mp == nil { + log.Errorf("PostMedia without body") + return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil + } + + // Парсим первую часть (предполагаем, что файл в поле "file") + part, err := mp.NextPart() + if err != nil { + log.Errorf("PostMedia without file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + defer part.Close() + + // Читаем ВЕСЬ файл в память (для небольших изображений — нормально) + // Если файлы могут быть большими — используйте лимитированный буфер (см. ниже) + data, err := io.ReadAll(part) + if err != nil { + log.Errorf("PostMedia cannot read file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + + if len(data) == 0 { + log.Errorf("PostMedia empty file") + return oapi.PostMediaUpload400JSONResponse("Empty file"), nil + } + + // Проверка MIME по первым 512 байтам + mimeType := http.DetectContentType(data) + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + log.Errorf("PostMedia bad type") + return oapi.PostMediaUpload400JSONResponse("Bad data type"), nil + } + + // Декодируем изображение из буфера + var img image.Image + switch mimeType { + case "image/jpeg": + { + img, err = jpeg.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/png": + { + img, err = png.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/webp": + { + img, err = webp.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + } + + // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.PNG) + if err != nil { + log.Errorf("PostMedia failed to re-encode JPEG: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + // TODO: to delete + filename := part.FileName() + if filename == "" { + filename = "upload_" + generateRandomHex(8) + ".jpg" + } else { + filename = sanitizeFilename(filename) + if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { + filename += ".jpg" + } + } + + // TODO: пойти на хуй ( вызвать файловую помойку) + err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + if err != nil { + log.Errorf("PostMedia failed to write: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + return oapi.PostMediaUpload200JSONResponse{}, nil +} + +// Вспомогательные функции — как раньше +func generateRandomHex(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = byte('a' + (i % 16)) + } + return fmt.Sprintf("%x", b) +} + +func sanitizeFilename(name string) string { + var clean strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '.' || r == '_' || r == '-' { + clean.WriteRune(r) + } + } + s := clean.String() + if s == "" { + return "file" + } + return s +} From 5acc53ec9d8bd34fc395bc5cd527d7935c470cad Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 04:34:18 +0300 Subject: [PATCH 08/35] fix --- modules/backend/handlers/images.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go index 5309480..c1e3d4b 100644 --- a/modules/backend/handlers/images.go +++ b/modules/backend/handlers/images.go @@ -85,7 +85,6 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo } } - // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.PNG) if err != nil { @@ -99,13 +98,14 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo filename = "upload_" + generateRandomHex(8) + ".jpg" } else { filename = sanitizeFilename(filename) - if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { - filename += ".jpg" + if !strings.HasSuffix(strings.ToLower(filename), ".png") { + filename += ".png" } } // TODO: пойти на хуй ( вызвать файловую помойку) - err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + os.Mkdir("uploads", 0644) + err = os.WriteFile(filepath.Join("./uploads", filename), buf.Bytes(), 0644) if err != nil { log.Errorf("PostMedia failed to write: %v", err) return oapi.PostMediaUpload500Response{}, nil From afb1db17bdaca025da47a8eb78c758278f71b6a3 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 04:51:04 +0300 Subject: [PATCH 09/35] feat: generateImpersonationToken function --- modules/auth/handlers/handlers.go | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2c4ee6c..9138fa7 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -56,7 +56,7 @@ func (s Server) generateImpersonationToken(userID string, impersonated_by string at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", err } @@ -159,7 +159,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } -func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { +func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -167,11 +167,30 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - token := ginCtx.Request.Header.Get("Authorization") + token, err := ExtractBearerToken(ginCtx.Request.Header.Get("Authorization")) + if err != nil { + // TODO: return 500 + log.Errorf("failed to extract bearer token: %v", err) + return auth.GetImpersonationToken401Response{}, err + } log.Printf("got auth token: %s", token) - //s.db.GetExternalServiceByToken() - return auth.PostSignIn401Response{}, nil + ext_service, err := s.db.GetExternalServiceByToken(context.Background(), &token) + if err != nil { + log.Errorf("failed to get external service by token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + // TODO: handle tgid + accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + if err != nil { + log.Errorf("failed to generate impersonation token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { @@ -266,3 +285,11 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper // Error: errStr, // }, nil // } + +func ExtractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if len(header) <= len(prefix) || header[:len(prefix)] != prefix { + return "", fmt.Errorf("invalid bearer token format") + } + return header[len(prefix):], nil +} From 8bd515c33f081bd062013ac29ae3e6313848de13 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 05:15:21 +0300 Subject: [PATCH 10/35] fix: GetImpersonationToken external_id handling --- auth/auth.gen.go | 6 +-- auth/openapi-auth.yaml | 4 +- modules/auth/handlers/handlers.go | 71 ++++++++++++------------------- modules/auth/queries.sql | 8 +++- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 2 +- sql/queries.sql.go | 29 +++++++++++++ 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 89a2168..1e8803e 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -19,9 +19,9 @@ const ( // GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. type GetImpersonationTokenJSONBody struct { - TgId *int64 `json:"tg_id,omitempty"` - UserId *int64 `json:"user_id,omitempty"` - union json.RawMessage + ExternalId *int64 `json:"external_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage } // GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 93db937..803a4ae 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -94,12 +94,12 @@ paths: user_id: type: integer format: int64 - tg_id: + external_id: type: integer format: int64 oneOf: - required: ["user_id"] - - required: ["tg_id"] + - required: ["external_id"] responses: "200": description: Generated impersonation access token diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9138fa7..2a6518e 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -182,8 +182,33 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona // TODO: check err and retyrn 400/500 } - // TODO: handle tgid - accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + var user_id string = "" + + if req.Body.ExternalId != nil { + user, err := s.db.GetUserByExternalServiceId(context.Background(), sqlc.GetUserByExternalServiceIdParams{ + ExternalID: fmt.Sprintf("%d", *req.Body.ExternalId), + ServiceID: ext_service.ID, + }) + if err != nil { + log.Errorf("failed to get user by external user id: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + user_id = fmt.Sprintf("%d", user.ID) + } + + if req.Body.UserId != nil { + if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { + log.Error("user_id and external_d are incorrect") + // TODO: 405 + return auth.GetImpersonationToken401Response{}, nil + } else { + user_id = fmt.Sprintf("%d", *req.Body.UserId) + } + } + + accessToken, err := s.generateImpersonationToken(user_id, fmt.Sprintf("%d", ext_service.ID)) if err != nil { log.Errorf("failed to generate impersonation token: %v", err) return auth.GetImpersonationToken401Response{}, err @@ -193,48 +218,6 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, 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 diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 363f07a..0b9b941 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -12,4 +12,10 @@ RETURNING id; -- name: GetExternalServiceByToken :one SELECT * FROM external_services -WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file +WHERE auth_token = sqlc.arg('auth_token'); + +-- name: GetUserByExternalServiceId :one +SELECT u.* +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = sqlc.arg('external_id') AND ei.service_id = sqlc.arg('service_id'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 9bf99dc..946fe7e 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -112,7 +112,7 @@ CREATE TABLE external_services ( CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), - service_id bigint REFERENCES external_services (id), + service_id bigint NOT NULL REFERENCES external_services (id), external_id text NOT NULL ); diff --git a/sql/models.go b/sql/models.go index 1395a19..c299609 100644 --- a/sql/models.go +++ b/sql/models.go @@ -188,7 +188,7 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { type ExternalID struct { UserID int64 `json:"user_id"` - ServiceID *int64 `json:"service_id"` + ServiceID int64 `json:"service_id"` ExternalID string `json:"external_id"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index e12619e..2d4067d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -251,6 +251,35 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe return items, nil } +const getUserByExternalServiceId = `-- name: GetUserByExternalServiceId :one +SELECT u.id, u.avatar_id, u.passhash, u.mail, u.nickname, u.disp_name, u.user_desc, u.creation_date, u.last_login +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = $1 AND ei.service_id = $2 +` + +type GetUserByExternalServiceIdParams struct { + ExternalID string `json:"external_id"` + ServiceID int64 `json:"service_id"` +} + +func (q *Queries) GetUserByExternalServiceId(ctx context.Context, arg GetUserByExternalServiceIdParams) (User, error) { + row := q.db.QueryRow(ctx, getUserByExternalServiceId, arg.ExternalID, arg.ServiceID) + 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 getUserByID = `-- name: GetUserByID :one SELECT t.id as id, From 00894f45266d2c67f108674cce5130752ff3dfd9 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 05:18:23 +0300 Subject: [PATCH 11/35] feat: ftime logic for usertitle is changed --- api/_build/openapi.yaml | 6 +++ api/api.gen.go | 6 ++- api/paths/users-id-titles-id.yaml | 3 ++ api/paths/users-id-titles.yaml | 3 ++ modules/backend/handlers/users.go | 12 +++++ modules/backend/queries.sql | 8 +-- modules/frontend/src/api/client.gen.ts | 2 +- modules/frontend/src/api/sdk.gen.ts | 7 ++- modules/frontend/src/api/types.gen.ts | 50 +++++++++++++++++++ .../src/pages/UsersPage/UsersPage.tsx | 0 sql/migrations/000001_init.up.sql | 15 +----- sql/queries.sql.go | 36 +++++++------ 12 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 modules/frontend/src/pages/UsersPage/UsersPage.tsx diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 9ed5b5f..ad0c9be 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -395,6 +395,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time required: - title_id - status @@ -478,6 +481,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/api.gen.go b/api/api.gen.go index d93e925..04d10c0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -262,7 +262,8 @@ type GetUserTitlesParams struct { // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status UserTitleStatus `json:"status"` @@ -271,7 +272,8 @@ type AddUserTitleJSONBody struct { // UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status *UserTitleStatus `json:"status,omitempty"` diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index 1da2b81..20a174f 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -61,6 +61,9 @@ patch: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 75f5461..f1e5e95 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -122,6 +122,9 @@ post: rate: type: integer format: int32 + ftime: + 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 995d5af..eecd82f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -69,6 +69,16 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { return nil } +func oapiDate2sql(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{ + Time: *t, + Valid: true, + } +} + // func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { // var sqlc_status SqlcUserStatus // if s == nil { @@ -365,6 +375,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.InsertUserTitle(ctx, params) @@ -428,6 +439,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Rate: request.Body.Rate, UserID: request.UserId, TitleID: request.TitleId, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.UpdateUserTitle(ctx, params) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 03502c4..19971e5 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -400,13 +400,14 @@ FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; -- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( sqlc.arg('user_id')::bigint, sqlc.arg('title_id')::bigint, sqlc.arg('status')::usertitle_status_t, sqlc.narg('rate')::int, - sqlc.narg('review_id')::bigint + sqlc.narg('review_id')::bigint, + sqlc.narg('ftime')::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime; @@ -415,7 +416,8 @@ RETURNING user_id, title_id, status, rate, review_id, ctime; UPDATE usertitles SET status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), - rate = COALESCE(sqlc.narg('rate')::int, rate) + rate = COALESCE(sqlc.narg('rate')::int, rate), + ctime = COALESCE(sqlc.narg('ftime')::timestamptz, ctime) WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts index 2de06ac..952c663 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 = (override?: Config) => Config & T>; -export const client = createClient(createConfig({ baseUrl: 'http://10.1.0.65:8081/api/v1' })); +export const client = createClient(createConfig({ baseUrl: '/api/v1' })); diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts index 5359156..7d46120 100644 --- a/modules/frontend/src/api/sdk.gen.ts +++ b/modules/frontend/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ 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'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; export type Options = Options2 & { /** @@ -32,6 +32,11 @@ export const getTitles = (options?: Option */ export const getTitle = (options: Options) => (options.client ?? client).get({ url: '/titles/{title_id}', ...options }); +/** + * Search user by nickname or dispname (both in one param), response is always sorted by id + */ +export const getUsers = (options?: Options) => (options?.client ?? client).get({ url: '/users/', ...options }); + /** * Get user info */ diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index ce4db4b..d4526a7 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -60,6 +60,12 @@ export type Title = { title_names: { [key: string]: Array; }; + /** + * Localized description. Key = language (ISO 639-1), value = description. + */ + title_desc?: { + [key: string]: string; + }; studio?: Studio; tags: Tags; poster?: Image; @@ -231,6 +237,50 @@ export type GetTitleResponses = { export type GetTitleResponse = GetTitleResponses[keyof GetTitleResponses]; +export type GetUsersData = { + body?: never; + path?: never; + query?: { + word?: string; + limit?: number; + /** + * pass cursor naked + */ + cursor_id?: number; + }; + url: '/users/'; +}; + +export type GetUsersErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUsersResponses = { + /** + * List of users with cursor + */ + 200: { + /** + * List of users + */ + data: Array; + cursor: number; + }; + /** + * No users found + */ + 204: void; +}; + +export type GetUsersResponse = GetUsersResponses[keyof GetUsersResponses]; + export type GetUsersIdData = { body?: never; path: { diff --git a/modules/frontend/src/pages/UsersPage/UsersPage.tsx b/modules/frontend/src/pages/UsersPage/UsersPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index d6353d6..d57b807 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -170,17 +170,4 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); - -CREATE OR REPLACE FUNCTION set_ctime() -RETURNS TRIGGER AS $$ -BEGIN - NEW.ctime = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_ctime_on_update -BEFORE UPDATE ON usertitles -FOR EACH ROW -EXECUTE FUNCTION set_ctime(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0c17599..d253cc9 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,6 +9,8 @@ import ( "context" "encoding/json" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -394,23 +396,25 @@ 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) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( $1::bigint, $2::bigint, $3::usertitle_status_t, $4::int, - $5::bigint + $5::bigint, + $6::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime ` type InsertUserTitleParams struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ftime pgtype.Timestamptz `json:"ftime"` } func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { @@ -420,6 +424,7 @@ func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams arg.Status, arg.Rate, arg.ReviewID, + arg.Ftime, ) var i Usertitle err := row.Scan( @@ -1017,18 +1022,20 @@ const updateUserTitle = `-- name: UpdateUserTitle :one UPDATE usertitles SET status = COALESCE($1::usertitle_status_t, status), - rate = COALESCE($2::int, rate) + rate = COALESCE($2::int, rate), + ctime = COALESCE($3::timestamptz, ctime) WHERE - user_id = $3 - AND title_id = $4 + user_id = $4 + AND title_id = $5 RETURNING user_id, title_id, status, rate, review_id, ctime ` type UpdateUserTitleParams struct { - Status *UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + Ftime pgtype.Timestamptz `json:"ftime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` } // Fails with sql.ErrNoRows if (user_id, title_id) not found @@ -1036,6 +1043,7 @@ func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams row := q.db.QueryRow(ctx, updateUserTitle, arg.Status, arg.Rate, + arg.Ftime, arg.UserID, arg.TitleID, ) From 6955216568c9db4cdb49d9771f333632010d5364 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 05:24:29 +0300 Subject: [PATCH 12/35] feat(cicd): added redis --- deploy/docker-compose.yml | 25 +++++++++++++++++++++---- modules/auth/handlers/handlers.go | 1 + 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1119335..3eff3d3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -40,6 +40,22 @@ services: retries: 5 start_period: 10s + redis: + image: redis:8.4.0-alpine + container_name: redis + ports: + - "6379:6379" + restart: always + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + volumes: + - redis_data:/data + nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest container_name: nyanimedb-backend @@ -51,8 +67,8 @@ services: RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} AUTH_ENABLED: ${AUTH_ENABLED} - ports: - - "8080:8080" + # ports: + # - "8080:8080" depends_on: - postgres - rabbitmq @@ -68,8 +84,8 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - ports: - - "8082:8082" + # ports: + # - "8082:8082" depends_on: - postgres networks: @@ -89,6 +105,7 @@ services: volumes: postgres_data: rabbitmq_data: + redis_data: networks: nyanimedb-network: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2a6518e..3af44f3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -199,6 +199,7 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona } if req.Body.UserId != nil { + // TODO: check user existence if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { log.Error("user_id and external_d are incorrect") // TODO: 405 From 713c0adc14ad57633fb243a358301935d301b293 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 06:25:21 +0300 Subject: [PATCH 13/35] feat: fully featured token checks --- auth/auth.gen.go | 87 +++++++++++++++ auth/claims.go | 10 ++ auth/openapi-auth.yaml | 22 +++- modules/auth/handlers/handlers.go | 154 +++++++++++++++----------- modules/auth/main.go | 2 +- modules/backend/middlewares/access.go | 28 +++-- 6 files changed, 226 insertions(+), 77 deletions(-) create mode 100644 auth/claims.go diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 1e8803e..fd7a224 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -56,6 +56,9 @@ type ServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(c *gin.Context) + // Refreshes access_token and refresh_token + // (GET /refresh-tokens) + RefreshTokens(c *gin.Context) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(c *gin.Context) @@ -88,6 +91,19 @@ func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { siw.Handler.GetImpersonationToken(c) } +// RefreshTokens operation middleware +func (siw *ServerInterfaceWrapper) RefreshTokens(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.RefreshTokens(c) +} + // PostSignIn operation middleware func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { @@ -142,10 +158,17 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) + router.GET(options.BaseURL+"/refresh-tokens", wrapper.RefreshTokens) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } +type ClientErrorResponse struct { +} + +type ServerErrorResponse struct { +} + type UnauthorizedErrorResponse struct { } @@ -176,6 +199,42 @@ func (response GetImpersonationToken401Response) VisitGetImpersonationTokenRespo return nil } +type RefreshTokensRequestObject struct { +} + +type RefreshTokensResponseObject interface { + VisitRefreshTokensResponse(w http.ResponseWriter) error +} + +type RefreshTokens200Response struct { +} + +func (response RefreshTokens200Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type RefreshTokens400Response = ClientErrorResponse + +func (response RefreshTokens400Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type RefreshTokens401Response = UnauthorizedErrorResponse + +func (response RefreshTokens401Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type RefreshTokens500Response = ServerErrorResponse + +func (response RefreshTokens500Response) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type PostSignInRequestObject struct { Body *PostSignInJSONRequestBody } @@ -227,6 +286,9 @@ type StrictServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) + // Refreshes access_token and refresh_token + // (GET /refresh-tokens) + RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) @@ -280,6 +342,31 @@ func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { } } +// RefreshTokens operation middleware +func (sh *strictHandler) RefreshTokens(ctx *gin.Context) { + var request RefreshTokensRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.RefreshTokens(ctx, request.(RefreshTokensRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RefreshTokens") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(RefreshTokensResponseObject); ok { + if err := validResponse.VisitRefreshTokensResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostSignIn operation middleware func (sh *strictHandler) PostSignIn(ctx *gin.Context) { var request PostSignInRequestObject diff --git a/auth/claims.go b/auth/claims.go new file mode 100644 index 0000000..d888a1b --- /dev/null +++ b/auth/claims.go @@ -0,0 +1,10 @@ +package auth + +import "github.com/golang-jwt/jwt/v5" + +type TokenClaims struct { + UserID string `json:"user_id"` + Type string `json:"type"` + ImpID *string `json:"imp_id,omitempty"` + jwt.RegisteredClaims +} diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 803a4ae..e95e8c2 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -116,6 +116,22 @@ paths: "401": $ref: '#/components/responses/UnauthorizedError' + /refresh-tokens: + get: + summary: Refreshes access_token and refresh_token + operationId: refreshTokens + tags: [Auth] + responses: + # This one sets two cookies: access_token and refresh_token + "200": + description: Refresh success + "400": + $ref: '#/components/responses/ClientError' + "401": + $ref: '#/components/responses/UnauthorizedError' + "500": + $ref: '#/components/responses/ServerError' + components: securitySchemes: bearerAuth: @@ -123,4 +139,8 @@ components: scheme: bearer responses: UnauthorizedError: - description: Access token is missing or invalid \ No newline at end of file + description: Access token is missing or invalid + ServerError: + description: ServerError + ClientError: + description: ClientError \ No newline at end of file diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 3af44f3..4f67448 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -47,28 +47,35 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func (s Server) generateImpersonationToken(userID string, impersonated_by string) (accessToken string, err error) { - accessClaims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(15 * time.Minute).Unix(), - "imp_id": impersonated_by, +func (s *Server) generateImpersonationToken(userID string, impersonatedBy string) (string, error) { + now := time.Now() + claims := auth.TokenClaims{ + UserID: userID, + ImpID: &impersonatedBy, + Type: "access", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + ID: generateJTI(), + }, } - at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - - accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) - if err != nil { - return "", err - } - - return accessToken, nil + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.JwtPrivateKey)) } -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(), - //TODO: add created_at +func (s *Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { + now := time.Now() + + // Access token (15 мин) + accessClaims := auth.TokenClaims{ + UserID: userID, + Type: "access", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + ID: generateJTI(), + }, } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) @@ -76,9 +83,15 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return "", "", "", err } - refreshClaims := jwt.MapClaims{ - "user_id": userID, - "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + // Refresh token (7 дней) + refreshClaims := auth.TokenClaims{ + UserID: userID, + Type: "refresh", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + ID: generateJTI(), + }, } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) @@ -86,6 +99,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return "", "", "", err } + // CSRF token csrfBytes := make([]byte, 32) _, err = rand.Read(csrfBytes) if err != nil { @@ -219,56 +233,56 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } -// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { -// valid := false -// var userID *string -// var errStr *string +func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequestObject) (auth.RefreshTokensResponseObject, error) { + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + return auth.RefreshTokens500Response{}, 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 refreshSecret, nil -// }) + rtCookie, err := ginCtx.Request.Cookie("refresh_token") + if err != nil { + log.Print("failed to get refresh_token cookie") + return auth.RefreshTokens400Response{}, fmt.Errorf("failed to get refresh_token cookie") + } -// if err != nil { -// e := err.Error() -// errStr = &e -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } + refreshToken := rtCookie.Value -// 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 -// } + token, err := jwt.ParseWithClaims(refreshToken, &auth.TokenClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(s.JwtPrivateKey), nil + }) + if err != nil || !token.Valid { + log.Print("invalid refresh token") + return auth.RefreshTokens401Response{}, nil + } -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } + claims, ok := token.Claims.(*auth.TokenClaims) + if !ok || claims.UserID == "" { + log.Print("invalid refresh token claims") + return auth.RefreshTokens401Response{}, nil + } + if claims.Type != "refresh" { + log.Errorf("token is not a refresh token") + return auth.RefreshTokens401Response{}, nil + } + + accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.UserID) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", claims.UserID, err) + return auth.RefreshTokens500Response{}, nil + } + + // TODO: check cookie settings carefully + 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, "/", "", false, false) + + return auth.RefreshTokens200Response{}, nil +} func ExtractBearerToken(header string) (string, error) { const prefix = "Bearer " @@ -277,3 +291,9 @@ func ExtractBearerToken(header string) (string, error) { } return header[len(prefix):], nil } + +func generateJTI() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7305b7d..bbeb014 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -46,7 +46,7 @@ func main() { log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go index 73200e8..8e787f8 100644 --- a/modules/backend/middlewares/access.go +++ b/modules/backend/middlewares/access.go @@ -3,8 +3,11 @@ package middleware import ( "context" "errors" + "fmt" "net/http" + "nyanimedb/auth" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) @@ -37,12 +40,18 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 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()) + token, err := jwt.ParseWithClaims(tokenStr, &auth.TokenClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") } - return []byte(secret), nil // ← конвертируем string → []byte + return []byte(secret), nil }) + // 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 @@ -55,20 +64,23 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 4. Извлекаем user_id из claims - claims, ok := token.Claims.(jwt.MapClaims) + claims, ok := token.Claims.(*auth.TokenClaims) if !ok { abortWithJSON(c, http.StatusUnauthorized, "invalid claims format") return } - userID, ok := claims["user_id"].(string) - if !ok || userID == "" { + if claims.UserID == "" { abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") return } + if claims.Type != "access" { + abortWithJSON(c, http.StatusUnauthorized, "token type is not access") + return + } // 5. Сохраняем в контексте - c.Set("user_id", userID) + c.Set("user_id", claims.UserID) // 6. Для oapi-codegen — кладём gin.Context в request context GinContextToContext(c) From 714ef5702722d388d60eb6513ee143109e07a90d Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 06:47:01 +0300 Subject: [PATCH 14/35] feat: use JWT Subject --- auth/claims.go | 5 ++--- modules/auth/handlers/handlers.go | 22 +++++++++++----------- modules/backend/middlewares/access.go | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/auth/claims.go b/auth/claims.go index d888a1b..6a97483 100644 --- a/auth/claims.go +++ b/auth/claims.go @@ -3,8 +3,7 @@ package auth import "github.com/golang-jwt/jwt/v5" type TokenClaims struct { - UserID string `json:"user_id"` - Type string `json:"type"` - ImpID *string `json:"imp_id,omitempty"` + Type string `json:"type"` + ImpID *string `json:"imp_id,omitempty"` jwt.RegisteredClaims } diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 4f67448..1813035 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -50,10 +50,10 @@ func CheckPassword(password, hash string) (bool, error) { func (s *Server) generateImpersonationToken(userID string, impersonatedBy string) (string, error) { now := time.Now() claims := auth.TokenClaims{ - UserID: userID, - ImpID: &impersonatedBy, - Type: "access", + ImpID: &impersonatedBy, + Type: "access", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ID: generateJTI(), @@ -69,9 +69,9 @@ func (s *Server) generateTokens(userID string) (accessToken string, refreshToken // Access token (15 мин) accessClaims := auth.TokenClaims{ - UserID: userID, - Type: "access", + Type: "access", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ID: generateJTI(), @@ -85,9 +85,9 @@ func (s *Server) generateTokens(userID string) (accessToken string, refreshToken // Refresh token (7 дней) refreshClaims := auth.TokenClaims{ - UserID: userID, - Type: "refresh", + Type: "refresh", RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), ID: generateJTI(), @@ -154,7 +154,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return auth.PostSignIn401Response{}, nil } - accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(fmt.Sprintf("%d", user.ID)) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -260,7 +260,7 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest } claims, ok := token.Claims.(*auth.TokenClaims) - if !ok || claims.UserID == "" { + if !ok || claims.Subject == "" { log.Print("invalid refresh token claims") return auth.RefreshTokens401Response{}, nil } @@ -269,9 +269,9 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest return auth.RefreshTokens401Response{}, nil } - accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.UserID) + accessToken, refreshToken, csrfToken, err := s.generateTokens(claims.Subject) if err != nil { - log.Errorf("failed to generate tokens for user %s: %v", claims.UserID, err) + log.Errorf("failed to generate tokens for user %s: %v", claims.Subject, err) return auth.RefreshTokens500Response{}, nil } diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go index 8e787f8..9b15f8f 100644 --- a/modules/backend/middlewares/access.go +++ b/modules/backend/middlewares/access.go @@ -70,7 +70,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { return } - if claims.UserID == "" { + if claims.Subject == "" { abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") return } @@ -80,7 +80,7 @@ func JWTAuthMiddleware(secret string) gin.HandlerFunc { } // 5. Сохраняем в контексте - c.Set("user_id", claims.UserID) + c.Set("user_id", claims.Subject) // 6. Для oapi-codegen — кладём gin.Context в request context GinContextToContext(c) From 69eacd724015227509ac2aa4b16618ce630ce581 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 07:01:38 +0300 Subject: [PATCH 15/35] feat: logout --- auth/auth.gen.go | 67 +++++++++++++++++++++++++++++++ auth/openapi-auth.yaml | 11 +++++ modules/auth/handlers/handlers.go | 16 ++++++++ 3 files changed, 94 insertions(+) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index fd7a224..ebef832 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -56,6 +56,9 @@ type ServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(c *gin.Context) + // Logs out the user + // (POST /logout) + Logout(c *gin.Context) // Refreshes access_token and refresh_token // (GET /refresh-tokens) RefreshTokens(c *gin.Context) @@ -91,6 +94,19 @@ func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { siw.Handler.GetImpersonationToken(c) } +// Logout operation middleware +func (siw *ServerInterfaceWrapper) Logout(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.Logout(c) +} + // RefreshTokens operation middleware func (siw *ServerInterfaceWrapper) RefreshTokens(c *gin.Context) { @@ -158,6 +174,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) + router.POST(options.BaseURL+"/logout", wrapper.Logout) router.GET(options.BaseURL+"/refresh-tokens", wrapper.RefreshTokens) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) @@ -199,6 +216,28 @@ func (response GetImpersonationToken401Response) VisitGetImpersonationTokenRespo return nil } +type LogoutRequestObject struct { +} + +type LogoutResponseObject interface { + VisitLogoutResponse(w http.ResponseWriter) error +} + +type Logout200Response struct { +} + +func (response Logout200Response) VisitLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type Logout500Response = ServerErrorResponse + +func (response Logout500Response) VisitLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type RefreshTokensRequestObject struct { } @@ -286,6 +325,9 @@ type StrictServerInterface interface { // Get service impersontaion token // (POST /get-impersonation-token) GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) + // Logs out the user + // (POST /logout) + Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) // Refreshes access_token and refresh_token // (GET /refresh-tokens) RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error) @@ -342,6 +384,31 @@ func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { } } +// Logout operation middleware +func (sh *strictHandler) Logout(ctx *gin.Context) { + var request LogoutRequestObject + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Logout") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(LogoutResponseObject); ok { + if err := validResponse.VisitLogoutResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // RefreshTokens operation middleware func (sh *strictHandler) RefreshTokens(ctx *gin.Context) { var request RefreshTokensRequestObject diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index e95e8c2..8603423 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -132,6 +132,17 @@ paths: "500": $ref: '#/components/responses/ServerError' + /logout: + post: + summary: Logs out the user + operationId: logout + tags: [Auth] + responses: + "200": + description: Logout success + "500": + $ref: '#/components/responses/ServerError' + components: securitySchemes: bearerAuth: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 1813035..163efc2 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -284,6 +284,22 @@ func (s Server) RefreshTokens(ctx context.Context, req auth.RefreshTokensRequest return auth.RefreshTokens200Response{}, nil } +func (s Server) Logout(ctx context.Context, req auth.LogoutRequestObject) (auth.LogoutResponseObject, error) { + // TODO: get current tokens and add them to block list + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + return auth.Logout500Response{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + // Delete cookies by setting MaxAge negative + ginCtx.SetCookie("access_token", "", -1, "/api", "", true, true) + ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", true, true) + ginCtx.SetCookie("xsrf_token", "", -1, "/", "", false, false) + + return auth.Logout200Response{}, nil +} + func ExtractBearerToken(header string) (string, error) { const prefix = "Bearer " if len(header) <= len(prefix) || header[:len(prefix)] != prefix { From da9d0f8dda1be3dc68bb144d5d367df1b96e041a Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 07:19:27 +0300 Subject: [PATCH 16/35] feat: frontend logout menu --- modules/frontend/src/App.tsx | 6 +- modules/frontend/src/auth/client.gen.ts | 16 + .../frontend/src/auth/client/client.gen.ts | 301 ++++++++++++++++ modules/frontend/src/auth/client/index.ts | 25 ++ modules/frontend/src/auth/client/types.gen.ts | 241 +++++++++++++ modules/frontend/src/auth/client/utils.gen.ts | 332 ++++++++++++++++++ modules/frontend/src/auth/core/ApiError.ts | 25 -- .../src/auth/core/ApiRequestOptions.ts | 17 - modules/frontend/src/auth/core/ApiResult.ts | 11 - .../src/auth/core/CancelablePromise.ts | 131 ------- modules/frontend/src/auth/core/OpenAPI.ts | 32 -- modules/frontend/src/auth/core/auth.gen.ts | 42 +++ .../src/auth/core/bodySerializer.gen.ts | 100 ++++++ modules/frontend/src/auth/core/params.gen.ts | 176 ++++++++++ .../src/auth/core/pathSerializer.gen.ts | 181 ++++++++++ .../src/auth/core/queryKeySerializer.gen.ts | 136 +++++++ modules/frontend/src/auth/core/request.ts | 323 ----------------- .../src/auth/core/serverSentEvents.gen.ts | 264 ++++++++++++++ modules/frontend/src/auth/core/types.gen.ts | 118 +++++++ modules/frontend/src/auth/core/utils.gen.ts | 143 ++++++++ modules/frontend/src/auth/index.ts | 12 +- modules/frontend/src/auth/sdk.gen.ts | 66 ++++ .../frontend/src/auth/services/AuthService.ts | 55 --- modules/frontend/src/auth/types.gen.ts | 136 +++++++ .../frontend/src/components/Header/Header.tsx | 113 +++--- .../src/pages/LoginPage/LoginPage.tsx | 98 ++---- 26 files changed, 2388 insertions(+), 712 deletions(-) create mode 100644 modules/frontend/src/auth/client.gen.ts create mode 100644 modules/frontend/src/auth/client/client.gen.ts create mode 100644 modules/frontend/src/auth/client/index.ts create mode 100644 modules/frontend/src/auth/client/types.gen.ts create mode 100644 modules/frontend/src/auth/client/utils.gen.ts delete mode 100644 modules/frontend/src/auth/core/ApiError.ts delete mode 100644 modules/frontend/src/auth/core/ApiRequestOptions.ts delete mode 100644 modules/frontend/src/auth/core/ApiResult.ts delete mode 100644 modules/frontend/src/auth/core/CancelablePromise.ts delete mode 100644 modules/frontend/src/auth/core/OpenAPI.ts create mode 100644 modules/frontend/src/auth/core/auth.gen.ts create mode 100644 modules/frontend/src/auth/core/bodySerializer.gen.ts create mode 100644 modules/frontend/src/auth/core/params.gen.ts create mode 100644 modules/frontend/src/auth/core/pathSerializer.gen.ts create mode 100644 modules/frontend/src/auth/core/queryKeySerializer.gen.ts delete mode 100644 modules/frontend/src/auth/core/request.ts create mode 100644 modules/frontend/src/auth/core/serverSentEvents.gen.ts create mode 100644 modules/frontend/src/auth/core/types.gen.ts create mode 100644 modules/frontend/src/auth/core/utils.gen.ts create mode 100644 modules/frontend/src/auth/sdk.gen.ts delete mode 100644 modules/frontend/src/auth/services/AuthService.ts create mode 100644 modules/frontend/src/auth/types.gen.ts diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 84c9086..a92cc17 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -11,12 +11,12 @@ import { Header } from "./components/Header/Header"; // OpenAPI.WITH_CREDENTIALS = true const App: React.FC = () => { - const username = localStorage.getItem("username") || undefined; - const userId = localStorage.getItem("userId"); + // const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("user_id"); return ( -
+
{/* auth */} } /> diff --git a/modules/frontend/src/auth/client.gen.ts b/modules/frontend/src/auth/client.gen.ts new file mode 100644 index 0000000..ba4855c --- /dev/null +++ b/modules/frontend/src/auth/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 = (override?: Config) => Config & T>; + +export const client = createClient(createConfig({ baseUrl: '/auth' })); diff --git a/modules/frontend/src/auth/client/client.gen.ts b/modules/frontend/src/auth/client/client.gen.ts new file mode 100644 index 0000000..c2a5190 --- /dev/null +++ b/modules/frontend/src/auth/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 & { + body?: any; + headers: ReturnType; +}; + +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) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => 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, + 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/auth/client/index.ts b/modules/frontend/src/auth/client/index.ts new file mode 100644 index 0000000..b295ede --- /dev/null +++ b/modules/frontend/src/auth/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/auth/client/types.gen.ts b/modules/frontend/src/auth/client/types.gen.ts new file mode 100644 index 0000000..b4a499c --- /dev/null +++ b/modules/frontend/src/auth/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 + extends Omit, + 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, + | '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; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + 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 + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? 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, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * 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 = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/modules/frontend/src/auth/client/utils.gen.ts b/modules/frontend/src/auth/client/utils.gen.ts new file mode 100644 index 0000000..4c48a9e --- /dev/null +++ b/modules/frontend/src/auth/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 = ({ + 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, + ...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 => { + 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 & { + 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, 'security'> & + Pick & { + 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['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 = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + 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 { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts deleted file mode 100644 index ec7b16a..0000000 --- a/modules/frontend/src/auth/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/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts deleted file mode 100644 index 93143c3..0000000 --- a/modules/frontend/src/auth/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; - readonly cookies?: Record; - readonly headers?: Record; - readonly query?: Record; - readonly formData?: Record; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record; -}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts deleted file mode 100644 index ee1126e..0000000 --- a/modules/frontend/src/auth/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/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts deleted file mode 100644 index d70de92..0000000 --- a/modules/frontend/src/auth/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 implements Promise { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise; - #resolve?: (value: T | PromiseLike) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike): 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( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike) | null - ): Promise { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch( - onRejected?: ((reason: any) => TResult | PromiseLike) | null - ): Promise { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise { - 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/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts deleted file mode 100644 index 79aa305..0000000 --- a/modules/frontend/src/auth/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 = (options: ApiRequestOptions) => Promise; -type Headers = Record; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver | undefined; - USERNAME?: string | Resolver | undefined; - PASSWORD?: string | Resolver | undefined; - HEADERS?: Headers | Resolver | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', - 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/auth/core/auth.gen.ts b/modules/frontend/src/auth/core/auth.gen.ts new file mode 100644 index 0000000..f8a7326 --- /dev/null +++ b/modules/frontend/src/auth/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, +): Promise => { + 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/auth/core/bodySerializer.gen.ts b/modules/frontend/src/auth/core/bodySerializer.gen.ts new file mode 100644 index 0000000..552b50f --- /dev/null +++ b/modules/frontend/src/auth/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; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +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: | Array>>( + 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: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + 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/auth/core/params.gen.ts b/modules/frontend/src/auth/core/params.gen.ts new file mode 100644 index 0000000..602715c --- /dev/null +++ b/modules/frontend/src/auth/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; + /** + * 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; + /** + * 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>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $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; + path: Record; + query: Record; +} + +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, + 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)[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)[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)[ + 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)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/modules/frontend/src/auth/core/pathSerializer.gen.ts b/modules/frontend/src/auth/core/pathSerializer.gen.ts new file mode 100644 index 0000000..8d99931 --- /dev/null +++ b/modules/frontend/src/auth/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @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 & { + 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 & { + value: Record | 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/auth/core/queryKeySerializer.gen.ts b/modules/frontend/src/auth/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d3bb683 --- /dev/null +++ b/modules/frontend/src/auth/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 => { + 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 = {}; + + 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/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts deleted file mode 100644 index 1dc6fef..0000000 --- a/modules/frontend/src/auth/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 = (value: T | null | undefined): value is Exclude => { - 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 => { - 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 = (options: ApiRequestOptions) => Promise; - -export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { - if (typeof resolver === 'function') { - return (resolver as Resolver)(options); - } - return resolver; -}; - -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { - 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); - - 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 ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance -): Promise> => { - 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; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record = { - 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 - * @throws ApiError - */ -export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { - 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(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/auth/core/serverSentEvents.gen.ts b/modules/frontend/src/auth/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..f8fd78e --- /dev/null +++ b/modules/frontend/src/auth/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 = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * 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; + /** + * 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) => 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; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + 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 | 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 = []; + 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/auth/core/types.gen.ts b/modules/frontend/src/auth/core/types.gen.ts new file mode 100644 index 0000000..643c070 --- /dev/null +++ b/modules/frontend/src/auth/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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/modules/frontend/src/auth/core/utils.gen.ts b/modules/frontend/src/auth/core/utils.gen.ts new file mode 100644 index 0000000..0b5389d --- /dev/null +++ b/modules/frontend/src/auth/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; + 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, + 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; + query?: Record; + 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/auth/index.ts b/modules/frontend/src/auth/index.ts index b0989c4..c352c10 100644 --- a/modules/frontend/src/auth/index.ts +++ b/modules/frontend/src/auth/index.ts @@ -1,10 +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 { AuthService } from './services/AuthService'; +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/modules/frontend/src/auth/sdk.gen.ts b/modules/frontend/src/auth/sdk.gen.ts new file mode 100644 index 0000000..f69153e --- /dev/null +++ b/modules/frontend/src/auth/sdk.gen.ts @@ -0,0 +1,66 @@ +// 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 { GetImpersonationTokenData, GetImpersonationTokenErrors, GetImpersonationTokenResponses, LogoutData, LogoutErrors, LogoutResponses, PostSignInData, PostSignInErrors, PostSignInResponses, PostSignUpData, PostSignUpResponses, RefreshTokensData, RefreshTokensErrors, RefreshTokensResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * 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; +}; + +/** + * Sign up a new user + */ +export const postSignUp = (options: Options) => (options.client ?? client).post({ + url: '/sign-up', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Sign in a user and return JWT + */ +export const postSignIn = (options: Options) => (options.client ?? client).post({ + url: '/sign-in', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get service impersontaion token + */ +export const getImpersonationToken = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/get-impersonation-token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Refreshes access_token and refresh_token + */ +export const refreshTokens = (options?: Options) => (options?.client ?? client).get({ url: '/refresh-tokens', ...options }); + +/** + * Logs out the user + */ +export const logout = (options?: Options) => (options?.client ?? client).post({ url: '/logout', ...options }); diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts deleted file mode 100644 index 74a8fa7..0000000 --- a/modules/frontend/src/auth/services/AuthService.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class AuthService { - /** - * Sign up a new user - * @param requestBody - * @returns any Sign-up result - * @throws ApiError - */ - public static postSignUp( - requestBody: { - nickname: string; - pass: string; - }, - ): CancelablePromise<{ - user_id: number; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/sign-up', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Sign in a user and return JWT - * @param requestBody - * @returns any Sign-in result with JWT - * @throws ApiError - */ - public static postSignIn( - requestBody: { - nickname: string; - pass: string; - }, - ): CancelablePromise<{ - user_id: number; - user_name: string; - }> { - return __request(OpenAPI, { - method: 'POST', - url: '/sign-in', - body: requestBody, - mediaType: 'application/json', - errors: { - 401: `Access denied due to invalid credentials`, - }, - }); - } -} diff --git a/modules/frontend/src/auth/types.gen.ts b/modules/frontend/src/auth/types.gen.ts new file mode 100644 index 0000000..5c0fdc0 --- /dev/null +++ b/modules/frontend/src/auth/types.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/auth` | (string & {}); +}; + +export type PostSignUpData = { + body: { + nickname: string; + pass: string; + }; + path?: never; + query?: never; + url: '/sign-up'; +}; + +export type PostSignUpResponses = { + /** + * Sign-up result + */ + 200: { + user_id: number; + }; +}; + +export type PostSignUpResponse = PostSignUpResponses[keyof PostSignUpResponses]; + +export type PostSignInData = { + body: { + nickname: string; + pass: string; + }; + path?: never; + query?: never; + url: '/sign-in'; +}; + +export type PostSignInErrors = { + /** + * Access token is missing or invalid + */ + 401: unknown; +}; + +export type PostSignInResponses = { + /** + * Sign-in result with JWT + */ + 200: { + user_id: number; + user_name: string; + }; +}; + +export type PostSignInResponse = PostSignInResponses[keyof PostSignInResponses]; + +export type GetImpersonationTokenData = { + body: unknown & { + user_id?: number; + external_id?: number; + }; + path?: never; + query?: never; + url: '/get-impersonation-token'; +}; + +export type GetImpersonationTokenErrors = { + /** + * Access token is missing or invalid + */ + 401: unknown; +}; + +export type GetImpersonationTokenResponses = { + /** + * Generated impersonation access token + */ + 200: { + /** + * JWT access token + */ + access_token: string; + }; +}; + +export type GetImpersonationTokenResponse = GetImpersonationTokenResponses[keyof GetImpersonationTokenResponses]; + +export type RefreshTokensData = { + body?: never; + path?: never; + query?: never; + url: '/refresh-tokens'; +}; + +export type RefreshTokensErrors = { + /** + * ClientError + */ + 400: unknown; + /** + * Access token is missing or invalid + */ + 401: unknown; + /** + * ServerError + */ + 500: unknown; +}; + +export type RefreshTokensResponses = { + /** + * Refresh success + */ + 200: unknown; +}; + +export type LogoutData = { + body?: never; + path?: never; + query?: never; + url: '/logout'; +}; + +export type LogoutErrors = { + /** + * ServerError + */ + 500: unknown; +}; + +export type LogoutResponses = { + /** + * Logout success + */ + 200: unknown; +}; diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 26f1658..3c86802 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -1,72 +1,98 @@ -import React, { useState } from "react"; -import { Link } from "react-router-dom"; +import React, { useState, useEffect, useRef } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; +import { logout } from "../../auth"; -type HeaderProps = { - username?: string; -}; - -export const Header: React.FC = ({ username }) => { +export const Header: React.FC = () => { + const navigate = useNavigate(); + const [username, setUsername] = useState(localStorage.getItem("user_name")); const [menuOpen, setMenuOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); - const toggleMenu = () => setMenuOpen(!menuOpen); + const dropdownRef = useRef(null); + + // Listen for localStorage changes to update username dynamically + useEffect(() => { + const handleStorage = () => setUsername(localStorage.getItem("user_name")); + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const handleLogout = async () => { + try { + await logout(); + localStorage.removeItem("user_id"); + localStorage.removeItem("user_name"); + setUsername(null); + navigate("/login"); + } catch (err) { + console.error(err); + } + }; + + // Close dropdown on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); return (
- {/* Левый блок — логотип / название */} + {/* Logo */}
NyanimeDB
- {/* Центр — ссылки на разделы (desktop) */} + {/* Navigation (desktop) */} - {/* Правый блок — профиль */} -
+ {/* Profile / login */} +
{username ? ( - - {username} - +
+ + {dropdownOpen && ( +
+ setDropdownOpen(false)}>Profile + +
+ )} +
) : ( - - Login - + Login )}
- {/* Бургер для мобильного */} + {/* Mobile burger */}
-
- {/* Мобильное меню */} + {/* Mobile menu */} {menuOpen && (
diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 928766e..4932a3b 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -1,10 +1,10 @@ import React, { useState } from "react"; -import { AuthService } from "../../auth/services/AuthService"; +import { postSignIn, postSignUp } from "../../auth"; import { useNavigate } from "react-router-dom"; export const LoginPage: React.FC = () => { const navigate = useNavigate(); - const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [isLogin, setIsLogin] = useState(true); const [nickname, setNickname] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -17,27 +17,30 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - 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.toString()); - localStorage.setItem("username", res.user_name); - - navigate("/profile"); // редирект на профиль + const res = await postSignIn({ body: { nickname, pass: password } }); + if (res.data?.user_id && res.data?.user_name) { + localStorage.setItem("user_id", res.data.user_id.toString()); + localStorage.setItem("user_name", res.data.user_name); + navigate("/profile"); } else { setError("Login failed"); } } else { - // SignUp оставляем без сохранения данных - const res = await AuthService.postSignUp({ nickname, pass: password }); - if (res.user_id) { - setIsLogin(true); // переключаемся на login после регистрации + // Sign up + const res = await postSignUp({ body: { nickname, pass: password } }); + if (res.data?.user_id) { + // Auto-login after sign-up + const loginRes = await postSignIn({ body: { nickname, pass: password } }); + if (loginRes.data?.user_id && loginRes.data?.user_name) { + localStorage.setItem("user_id", loginRes.data.user_id.toString()); + localStorage.setItem("user_name", loginRes.data.user_name); + navigate("/profile"); + } } else { setError("Sign up failed"); } } } catch (err: any) { - console.error(err); setError(err?.message || "Something went wrong"); } finally { setLoading(false); @@ -47,39 +50,26 @@ export const LoginPage: React.FC = () => { return (
-

- {isLogin ? "Login" : "Sign Up"} -

- +

{isLogin ? "Login" : "Sign Up"}

{error &&
{error}
}
-
- - setNickname(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - required - /> -
- -
- - setPassword(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - required - /> -
- + setNickname(e.target.value)} + placeholder="Nickname" + className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + required + /> + setPassword(e.target.value)} + placeholder="Password" + className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" + required + /> - + <>Don't have an account? ) : ( - <> - Already have an account?{" "} - - + <>Already have an account? )}
From 5fb7b16c9611a8a5b377220e069f733a82b57b98 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 07:21:21 +0300 Subject: [PATCH 17/35] fix: temp change reset cookie to non secure --- modules/auth/handlers/handlers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 163efc2..0569b59 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -293,8 +293,9 @@ func (s Server) Logout(ctx context.Context, req auth.LogoutRequestObject) (auth. } // Delete cookies by setting MaxAge negative - ginCtx.SetCookie("access_token", "", -1, "/api", "", true, true) - ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", true, true) + // TODO: change secure to true + ginCtx.SetCookie("access_token", "", -1, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", "", -1, "/auth", "", false, true) ginCtx.SetCookie("xsrf_token", "", -1, "/", "", false, false) return auth.Logout200Response{}, nil From 486c6d8407148ed88e6794e8071e2425bae31904 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 07:25:12 +0300 Subject: [PATCH 18/35] fix(front): local storage user id name --- .../src/components/TitleStatusControls/TitleStatusControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 98fa868..fc652af 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -27,7 +27,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { const [currentStatus, setCurrentStatus] = useState(null); const [loading, setLoading] = useState(false); - const userIdStr = localStorage.getItem("userId"); + const userIdStr = localStorage.getItem("user_id"); const userId = userIdStr ? Number(userIdStr) : null; // --- Load initial status --- From 1b6c536b773b202af6d11bd53284bf64ce2e387a Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 07:49:26 +0300 Subject: [PATCH 19/35] fix: usertitle ftime logic --- modules/frontend/src/api/sdk.gen.ts | 17 +++++++++++- modules/frontend/src/api/types.gen.ts | 37 +++++++++++++++++++++++++++ sql/migrations/000001_init.up.sql | 3 ++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts index 7d46120..24153db 100644 --- a/modules/frontend/src/api/sdk.gen.ts +++ b/modules/frontend/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ 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, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, PostMediaUploadData, PostMediaUploadErrors, PostMediaUploadResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; export type Options = Options2 & { /** @@ -113,3 +113,18 @@ export const updateUserTitle = (options: O ...options.headers } }); + +/** + * Upload an image (PNG, JPEG, or WebP) + * + * Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + * + */ +export const postMediaUpload = (options: Options) => (options.client ?? client).post({ + url: '/media/upload', + ...options, + headers: { + 'Content-Type': 'encoding', + ...options.headers + } +}); diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index d4526a7..d0ca425 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -453,6 +453,7 @@ export type AddUserTitleData = { title_id: number; status: UserTitleStatus; rate?: number; + ftime?: string; }; path: { /** @@ -578,6 +579,7 @@ export type UpdateUserTitleData = { body: { status?: UserTitleStatus; rate?: number; + ftime?: string; }; path: { user_id: number; @@ -618,3 +620,38 @@ export type UpdateUserTitleResponses = { }; export type UpdateUserTitleResponse = UpdateUserTitleResponses[keyof UpdateUserTitleResponses]; + +export type PostMediaUploadData = { + body: unknown; + path?: never; + query?: never; + url: '/media/upload'; +}; + +export type PostMediaUploadErrors = { + /** + * Bad request — e.g., invalid/malformed image, empty file + */ + 400: string; + /** + * Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + * or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + * + */ + 415: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type PostMediaUploadError = PostMediaUploadErrors[keyof PostMediaUploadErrors]; + +export type PostMediaUploadResponses = { + /** + * Image uploaded successfully + */ + 200: Image; +}; + +export type PostMediaUploadResponse = PostMediaUploadResponses[keyof PostMediaUploadResponses]; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 369e455..415b9af 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,8 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now() + ctime timestamptz NOT NULL DEFAULT now(), + ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); From f983ed10354b94b2a4930ba7e10ebca2e494f54a Mon Sep 17 00:00:00 2001 From: garaev kamil Date: Sat, 6 Dec 2025 08:01:12 +0300 Subject: [PATCH 20/35] Dockerfiles added --- Dockerfiles/Dockerfile_etl | 21 +++++++++++++++++++++ Dockerfiles/Dockerfile_image_storage | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 Dockerfiles/Dockerfile_etl create mode 100644 Dockerfiles/Dockerfile_image_storage diff --git a/Dockerfiles/Dockerfile_etl b/Dockerfiles/Dockerfile_etl new file mode 100644 index 0000000..ddb0bb3 --- /dev/null +++ b/Dockerfiles/Dockerfile_etl @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app/modules/anime_etl + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY modules/anime_etl/pyproject.toml modules/anime_etl/uv.lock ./ + +RUN pip install --no-cache-dir uv \ + && uv sync --frozen --no-dev + +COPY modules/anime_etl ./ + +ENV NYANIMEDB_MEDIA_ROOT=/media + +# было: CMD ["python", "-m", "rabbit_worker"] +CMD ["uv", "run", "python", "-m", "rabbit_worker"] diff --git a/Dockerfiles/Dockerfile_image_storage b/Dockerfiles/Dockerfile_image_storage new file mode 100644 index 0000000..34d7496 --- /dev/null +++ b/Dockerfiles/Dockerfile_image_storage @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +# каталог внутри контейнера +WORKDIR /app/modules/image_storage + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# 1. зависимости через uv +COPY modules/image_storage/pyproject.toml modules/image_storage/uv.lock ./ + +RUN pip install --no-cache-dir uv \ + && uv sync --frozen --no-dev + +# 2. сам код +COPY modules/image_storage ./ + +# 3. где будем хранить файлы внутри контейнера +ENV NYANIMEDB_MEDIA_ROOT=/media + +EXPOSE 8000 + +# 4. поднимаем FastAPI-приложение (app/main.py → app.main:app) +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] From fe1bf7ec1070bcbedac6fe64971b51832341e5e5 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 08:38:34 +0300 Subject: [PATCH 21/35] fix(etl): sql enum for image storage --- modules/anime_etl/db/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/anime_etl/db/repository.py b/modules/anime_etl/db/repository.py index 4c5caee..7c09329 100644 --- a/modules/anime_etl/db/repository.py +++ b/modules/anime_etl/db/repository.py @@ -80,7 +80,7 @@ async def get_or_create_image( VALUES (%s, %s) RETURNING id """, - ("image-service", rel_path), + ("local", rel_path), ) row = await cur.fetchone() return row["id"] From eef3696e5ee6bbd4e97dd50f7a0bbf1e5582a4f7 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 09:17:06 +0300 Subject: [PATCH 22/35] fix(cicd): refact etl dockerfile --- Dockerfiles/Dockerfile_etl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfiles/Dockerfile_etl b/Dockerfiles/Dockerfile_etl index ddb0bb3..c721b51 100644 --- a/Dockerfiles/Dockerfile_etl +++ b/Dockerfiles/Dockerfile_etl @@ -2,11 +2,11 @@ FROM python:3.12-slim WORKDIR /app/modules/anime_etl -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - libpq-dev \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# build-essential \ +# libpq-dev \ +# ca-certificates \ +# && rm -rf /var/lib/apt/lists/* COPY modules/anime_etl/pyproject.toml modules/anime_etl/uv.lock ./ From 7787eb328f0590cdd102e6458e1b84c0ca06e2ab Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 09:17:45 +0300 Subject: [PATCH 23/35] feat: media routing --- modules/frontend/nginx-default.conf | 10 ++++++++++ modules/image_storage/app/main.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index c3a851f..6075999 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -28,6 +28,16 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + + location /media/ { + rewrite ^/media/(.*)$ /$1 break; + proxy_pass http://nyanimedb-images:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/image_storage/app/main.py b/modules/image_storage/app/main.py index ff59d36..2c915c3 100644 --- a/modules/image_storage/app/main.py +++ b/modules/image_storage/app/main.py @@ -99,7 +99,7 @@ async def download_by_url(payload: DownloadByUrlRequest): return {"path": rel} -@app.get("/media/{path:path}") +@app.get("/{path:path}") async def get_image(path: str): """ Отдаёт файл по относительному пути (например, posters/ab/cd/hash.jpg). From 6f808a715bdaf8f8eb10ab88ed5836a5d82dadeb Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 09:30:18 +0300 Subject: [PATCH 24/35] fix --- modules/backend/queries.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 19971e5..5f0c74e 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -409,7 +409,7 @@ VALUES ( sqlc.narg('review_id')::bigint, sqlc.narg('ftime')::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime; +RETURNING *; -- name: UpdateUserTitle :one -- Fails with sql.ErrNoRows if (user_id, title_id) not found From e62f0fa96c6637741ef5ea64711c7db098937d12 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 09:51:01 +0300 Subject: [PATCH 25/35] feat!: FUCK FTIME --- api/_build/openapi.yaml | 4 ++-- api/api.gen.go | 4 ++-- api/schemas/UserTitle.yaml | 2 +- api/schemas/UserTitleMini.yaml | 2 +- modules/backend/handlers/users.go | 8 ++++---- modules/backend/queries.sql | 10 +++++----- modules/frontend/src/api/types.gen.ts | 4 ++-- sql/migrations/000001_init.up.sql | 1 - sql/models.go | 2 +- sql/queries.sql.go | 28 +++++++++++++-------------- 10 files changed, 32 insertions(+), 33 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index ad0c9be..738fdde 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -800,7 +800,7 @@ components: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time required: @@ -824,7 +824,7 @@ components: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time required: diff --git a/api/api.gen.go b/api/api.gen.go index 04d10c0..39c7080 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -156,7 +156,7 @@ type User struct { // UserTitle defines model for UserTitle. type UserTitle struct { - Ctime *time.Time `json:"ctime,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` Rate *int32 `json:"rate,omitempty"` ReviewId *int64 `json:"review_id,omitempty"` @@ -168,7 +168,7 @@ type UserTitle struct { // UserTitleMini defines model for UserTitleMini. type UserTitleMini struct { - Ctime *time.Time `json:"ctime,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` Rate *int32 `json:"rate,omitempty"` ReviewId *int64 `json:"review_id,omitempty"` diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index ef619cb..0b114a3 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -17,6 +17,6 @@ properties: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml index e1a5a74..0467252 100644 --- a/api/schemas/UserTitleMini.yaml +++ b/api/schemas/UserTitleMini.yaml @@ -18,6 +18,6 @@ properties: review_id: type: integer format: int64 - ctime: + ftime: type: string format: date-time diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index eecd82f..e215e64 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -167,7 +167,7 @@ func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, err func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ - Ctime: &t.UserCtime, + Ftime: &t.UserFtime, Rate: t.UserRate, ReviewId: t.ReviewID, // Status: , @@ -398,7 +398,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque return oapi.AddUserTitle500Response{}, nil } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, @@ -457,7 +457,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, @@ -487,7 +487,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque return oapi.GetUserTitle500Response{}, nil } oapi_usertitle := oapi.UserTitleMini{ - Ctime: &user_title.Ctime, + Ftime: &user_title.Ftime, Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 19971e5..7117456 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -268,7 +268,7 @@ SELECT u.status as usertitle_status, u.rate as user_rate, u.review_id as review_id, - u.ctime as user_ctime, + u.ftime as user_ftime, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -370,7 +370,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ftime, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN @@ -400,7 +400,7 @@ FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; -- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ftime) VALUES ( sqlc.arg('user_id')::bigint, sqlc.arg('title_id')::bigint, @@ -409,7 +409,7 @@ VALUES ( sqlc.narg('review_id')::bigint, sqlc.narg('ftime')::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime; +RETURNING user_id, title_id, status, rate, review_id, ftime; -- name: UpdateUserTitle :one -- Fails with sql.ErrNoRows if (user_id, title_id) not found @@ -417,7 +417,7 @@ UPDATE usertitles SET status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), rate = COALESCE(sqlc.narg('rate')::int, rate), - ctime = COALESCE(sqlc.narg('ftime')::timestamptz, ctime) + ftime = COALESCE(sqlc.narg('ftime')::timestamptz, ftime) WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index d0ca425..1352aa6 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -125,7 +125,7 @@ export type UserTitle = { status: UserTitleStatus; rate?: number; review_id?: number; - ctime?: string; + ftime?: string; }; export type UserTitleMini = { @@ -134,7 +134,7 @@ export type UserTitleMini = { status: UserTitleStatus; rate?: number; review_id?: number; - ctime?: string; + ftime?: string; }; export type Review = { diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 415b9af..57aa238 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,6 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now(), ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); diff --git a/sql/models.go b/sql/models.go index c299609..3a25d7d 100644 --- a/sql/models.go +++ b/sql/models.go @@ -284,5 +284,5 @@ type Usertitle struct { Status UsertitleStatusT `json:"status"` Rate *int32 `json:"rate"` ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` + Ftime time.Time `json:"ftime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0384ccd..0c863e8 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -54,7 +54,7 @@ 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 +RETURNING user_id, title_id, status, rate, review_id, ftime ` type DeleteUserTitleParams struct { @@ -71,7 +71,7 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -352,7 +352,7 @@ 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 + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ftime FROM usertitles as ut WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` @@ -371,7 +371,7 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -438,7 +438,7 @@ 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, ctime) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ftime) VALUES ( $1::bigint, $2::bigint, @@ -447,7 +447,7 @@ VALUES ( $5::bigint, $6::timestamptz ) -RETURNING user_id, title_id, status, rate, review_id, ctime +RETURNING user_id, title_id, status, rate, review_id, ftime ` type InsertUserTitleParams struct { @@ -475,7 +475,7 @@ func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } @@ -786,7 +786,7 @@ SELECT u.status as usertitle_status, u.rate as user_rate, u.review_id as review_id, - u.ctime as user_ctime, + u.ftime as user_ftime, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -888,7 +888,7 @@ WHERE AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) GROUP BY - t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ftime, i.id, s.id ORDER BY CASE WHEN $2::boolean THEN @@ -946,7 +946,7 @@ type SearchUserTitlesRow struct { UsertitleStatus UsertitleStatusT `json:"usertitle_status"` UserRate *int32 `json:"user_rate"` ReviewID *int64 `json:"review_id"` - UserCtime time.Time `json:"user_ctime"` + UserFtime time.Time `json:"user_ftime"` TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` @@ -994,7 +994,7 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.UsertitleStatus, &i.UserRate, &i.ReviewID, - &i.UserCtime, + &i.UserFtime, &i.TitleStorageType, &i.TitleImagePath, &i.TagNames, @@ -1065,11 +1065,11 @@ UPDATE usertitles SET status = COALESCE($1::usertitle_status_t, status), rate = COALESCE($2::int, rate), - ctime = COALESCE($3::timestamptz, ctime) + ftime = COALESCE($3::timestamptz, ftime) WHERE user_id = $4 AND title_id = $5 -RETURNING user_id, title_id, status, rate, review_id, ctime +RETURNING user_id, title_id, status, rate, review_id, ftime ` type UpdateUserTitleParams struct { @@ -1096,7 +1096,7 @@ func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams &i.Status, &i.Rate, &i.ReviewID, - &i.Ctime, + &i.Ftime, ) return i, err } From 103a872be21c015e3b286c975c8c81431bf82193 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 09:53:02 +0300 Subject: [PATCH 26/35] fix --- modules/backend/handlers/users.go | 3 +++ sql/migrations/000001_init.up.sql | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index eecd82f..c0f0f55 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -386,6 +386,9 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque // fmt.Println(pgErr.Code) // => 42601 if pgErr.Code == pgErrDuplicateKey { //duplicate key value return oapi.AddUserTitle409Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil } } else { log.Errorf("%v", err) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 415b9af..57aa238 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -86,7 +86,6 @@ CREATE TABLE usertitles ( status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, - ctime timestamptz NOT NULL DEFAULT now(), ftime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); From 541d0fce2704d39aa7ec7aea0987d3ac5d7af694 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 09:55:42 +0300 Subject: [PATCH 27/35] fix: ftime now set to now() if not received in backend --- modules/backend/handlers/users.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 5bbffea..100835a 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -71,7 +71,10 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { func oapiDate2sql(t *time.Time) pgtype.Timestamptz { if t == nil { - return pgtype.Timestamptz{Valid: false} + return pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + } } return pgtype.Timestamptz{ Time: *t, From 8056946f0373c2607af6eb4369a646272cc5245a Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 10:14:05 +0300 Subject: [PATCH 28/35] fix: media path --- .../components/cards/TitleCardHorizontal.tsx | 2 +- .../src/components/cards/TitleCardSquare.tsx | 2 +- .../cards/UserTitleCardHorizontal.tsx | 2 +- .../components/cards/UserTitleCardSquare.tsx | 2 +- .../src/pages/SettingsPage/SettingsPage.tsx | 154 ++++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 2 +- 6 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 modules/frontend/src/pages/SettingsPage/SettingsPage.tsx diff --git a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx index b848702..4a020b7 100644 --- a/modules/frontend/src/components/cards/TitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/TitleCardHorizontal.tsx @@ -10,7 +10,7 @@ export function TitleCardHorizontal({ title }: { title: Title }) { borderRadius: 8 }}> {title.poster?.image_path && ( - + )}

{title.title_names["en"]}

diff --git a/modules/frontend/src/components/cards/TitleCardSquare.tsx b/modules/frontend/src/components/cards/TitleCardSquare.tsx index 0bcb49d..6a7a071 100644 --- a/modules/frontend/src/components/cards/TitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/TitleCardSquare.tsx @@ -10,7 +10,7 @@ export function TitleCardSquare({ title }: { title: Title }) { textAlign: "center" }}> {title.poster?.image_path && ( - + )}

{title.title_names["en"]}

diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx index ad7d5df..aec30d2 100644 --- a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -10,7 +10,7 @@ export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { borderRadius: 8 }}> {title.title?.poster?.image_path && ( - + )}

{title.title?.title_names["en"]}

diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx index edcf1d5..a9424f6 100644 --- a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -10,7 +10,7 @@ export function UserTitleCardSquare({ title }: { title: UserTitle }) { textAlign: "center" }}> {title.title?.poster?.image_path && ( - + )}

{title.title?.title_names["en"]}

diff --git a/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000..16c7e9e --- /dev/null +++ b/modules/frontend/src/pages/SettingsPage/SettingsPage.tsx @@ -0,0 +1,154 @@ +// import React, { useEffect, useState } from "react"; +// import { updateUser, getUsersId } from "../../api"; +// import { useNavigate } from "react-router-dom"; + +// export const SettingsPage: React.FC = () => { +// const navigate = useNavigate(); + +// const userId = Number(localStorage.getItem("user_id")); +// const initialNickname = localStorage.getItem("user_name") || ""; +// const [mail, setMail] = useState(""); +// const [nickname, setNickname] = useState(initialNickname); +// const [dispName, setDispName] = useState(""); +// const [userDesc, setUserDesc] = useState(""); +// const [avatarId, setAvatarId] = useState(null); + +// const [loading, setLoading] = useState(false); +// const [success, setSuccess] = useState(null); +// const [error, setError] = useState(null); + +// useEffect(() => { +// const fetch = async () => { +// const res = await getUsersId({ +// path: { user_id: String(userId) }, +// }); + +// setProfile(res.data); +// }; + +// fetch(); +// }, [userId]); + +// const saveSettings = async (e: React.FormEvent) => { +// e.preventDefault(); +// setLoading(true); +// setSuccess(null); +// setError(null); + +// try { +// const res = await updateUser({ +// path: { user_id: userId }, +// body: { +// ...(mail ? { mail } : {}), +// ...(nickname ? { nickname } : {}), +// ...(dispName ? { disp_name: dispName } : {}), +// ...(userDesc ? { user_desc: userDesc } : {}), +// ...(avatarId !== undefined ? { avatar_id: avatarId } : {}), +// } +// }); + +// // Обновляем локальное отображение username +// if (nickname) { +// localStorage.setItem("user_name", nickname); +// window.dispatchEvent(new Event("storage")); // чтобы Header обновился +// } + +// setSuccess("Settings updated!"); +// setTimeout(() => navigate("/profile"), 800); + +// } catch (err: any) { +// console.error(err); +// setError(err?.message || "Failed to update settings"); +// } finally { +// setLoading(false); +// } +// }; + +// return ( +//
+//

User Settings

+ +// {success &&
{success}
} +// {error &&
{error}
} + +// +// {/* Email */} +//
+// +// setMail(e.target.value)} +// placeholder="example@mail.com" +// className="w-full px-4 py-2 border rounded-lg" +// /> +//
+ +// {/* Nickname */} +//
+// +// setNickname(e.target.value)} +// className="w-full px-4 py-2 border rounded-lg" +// /> +//
+ +// {/* Display name */} +//
+// +// setDispName(e.target.value)} +// placeholder="Shown name" +// className="w-full px-4 py-2 border rounded-lg" +// /> +//
+ +// {/* Bio */} +//
+// +//