Merge branch 'dev' into auth

This commit is contained in:
nihonium 2025-12-04 07:18:21 +03:00
commit e316617175
Signed by: nihonium
GPG key ID: 0251623741027CFC
24 changed files with 378 additions and 26 deletions

View file

@ -120,6 +120,8 @@ paths:
description: Title not found description: Title not found
'500': '500':
description: Unknown server error description: Unknown server error
security:
- JwtAuthCookies: []
'/users/{user_id}': '/users/{user_id}':
get: get:
operationId: getUsersId operationId: getUsersId
@ -148,6 +150,8 @@ paths:
description: User not found description: User not found
'500': '500':
description: Unknown server error description: Unknown server error
security:
- JwtAuthCookies: []
patch: patch:
operationId: updateUser operationId: updateUser
summary: Partially update a user account summary: Partially update a user account
@ -156,6 +160,7 @@ paths:
Password updates must be done via the dedicated auth-service (`/auth/`). Password updates must be done via the dedicated auth-service (`/auth/`).
Fields not provided in the request body remain unchanged. Fields not provided in the request body remain unchanged.
parameters: parameters:
- $ref: '#/components/parameters/csrfTokenHeader'
- name: user_id - name: user_id
in: path in: path
description: User ID (primary key) description: User ID (primary key)
@ -223,6 +228,8 @@ paths:
description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)'
'500': '500':
description: Unknown server error description: Unknown server error
security:
- JwtAuthCookies: []
'/users/{user_id}/titles': '/users/{user_id}/titles':
get: get:
operationId: getUserTitles operationId: getUserTitles
@ -398,11 +405,14 @@ paths:
description: User or title not found description: User or title not found
'500': '500':
description: Unknown server error description: Unknown server error
security:
- JwtAuthCookies: []
patch: patch:
operationId: updateUserTitle operationId: updateUserTitle
summary: Update a usertitle summary: Update a usertitle
description: User updating title list of watched description: User updating title list of watched
parameters: parameters:
- $ref: '#/components/parameters/csrfTokenHeader'
- name: user_id - name: user_id
in: path in: path
required: true required: true
@ -444,11 +454,14 @@ paths:
description: User or Title not found description: User or Title not found
'500': '500':
description: Internal server error description: Internal server error
security:
- JwtAuthCookies: []
delete: delete:
operationId: deleteUserTitle operationId: deleteUserTitle
summary: Delete a usertitle summary: Delete a usertitle
description: User deleting title from list of watched description: User deleting title from list of watched
parameters: parameters:
- $ref: '#/components/parameters/csrfTokenHeader'
- name: user_id - name: user_id
in: path in: path
required: true required: true
@ -472,8 +485,43 @@ paths:
description: User or Title not found description: User or Title not found
'500': '500':
description: Internal server error description: Internal server error
security:
- JwtAuthCookies: []
components: components:
parameters: parameters:
accessToken:
name: access_token
in: cookie
required: true
schema:
type: string
format: jwt
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y
description: |
JWT access token.
csrfToken:
name: xsrf_token
in: cookie
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9_-]{32,64}$'
example: abc123def456ghi789jkl012mno345pqr
description: |
Anti-CSRF token (Double Submit Cookie pattern).
Stored in non-HttpOnly cookie, readable by JavaScript.
Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE).
csrfTokenHeader:
name: X-XSRF-TOKEN
in: header
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9_-]{32,64}$'
description: |
Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
Required for all state-changing requests (POST/PUT/PATCH/DELETE).
example: abc123def456ghi789jkl012mno345pqr
cursor: cursor:
in: query in: query
name: cursor name: cursor

View file

@ -16,6 +16,10 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
) )
const (
JwtAuthCookiesScopes = "JwtAuthCookies.Scopes"
)
// Defines values for ReleaseSeason. // Defines values for ReleaseSeason.
const ( const (
Fall ReleaseSeason = "fall" Fall ReleaseSeason = "fall"
@ -170,6 +174,12 @@ type UserTitleMini struct {
// UserTitleStatus User's title status // UserTitleStatus User's title status
type UserTitleStatus string type UserTitleStatus string
// AccessToken defines model for accessToken.
type AccessToken = string
// CsrfToken defines model for csrfToken.
type CsrfToken = string
// Cursor defines model for cursor. // Cursor defines model for cursor.
type Cursor = string type Cursor = string
@ -219,6 +229,17 @@ type UpdateUserJSONBody struct {
UserDesc *string `json:"user_desc,omitempty"` UserDesc *string `json:"user_desc,omitempty"`
} }
// UpdateUserParams defines parameters for UpdateUser.
type UpdateUserParams struct {
// AccessToken JWT access token.
AccessToken AccessToken `form:"access_token" json:"access_token"`
// XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern).
// Stored in non-HttpOnly cookie, readable by JavaScript.
// Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE).
XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"`
}
// GetUserTitlesParams defines parameters for GetUserTitles. // GetUserTitlesParams defines parameters for GetUserTitles.
type GetUserTitlesParams struct { type GetUserTitlesParams struct {
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
@ -276,7 +297,7 @@ type ServerInterface interface {
GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) GetUsersId(c *gin.Context, userId string, params GetUsersIdParams)
// Partially update a user account // Partially update a user account
// (PATCH /users/{user_id}) // (PATCH /users/{user_id})
UpdateUser(c *gin.Context, userId int64) UpdateUser(c *gin.Context, userId int64, params UpdateUserParams)
// Get user titles // Get user titles
// (GET /users/{user_id}/titles) // (GET /users/{user_id}/titles)
GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams)
@ -431,6 +452,8 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) {
return return
} }
c.Set(JwtAuthCookiesScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context // Parameter object where we will unmarshal all parameters from the context
var params GetTitleParams var params GetTitleParams
@ -501,6 +524,47 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) {
return return
} }
c.Set(JwtAuthCookiesScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context
var params UpdateUserParams
{
var cookie string
if cookie, err = c.Cookie("access_token"); err == nil {
var value AccessToken
err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest)
return
}
params.AccessToken = value
} else {
siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest)
return
}
}
{
var cookie string
if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil {
var value CsrfToken
err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest)
return
}
params.XSRFTOKEN = value
} else {
siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest)
return
}
}
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -508,7 +572,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) {
} }
} }
siw.Handler.UpdateUser(c, userId) siw.Handler.UpdateUser(c, userId, params)
} }
// GetUserTitles operation middleware // GetUserTitles operation middleware
@ -935,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri
type UpdateUserRequestObject struct { type UpdateUserRequestObject struct {
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
Params UpdateUserParams
Body *UpdateUserJSONRequestBody Body *UpdateUserJSONRequestBody
} }
@ -1411,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU
} }
// UpdateUser operation middleware // UpdateUser operation middleware
func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) {
var request UpdateUserRequestObject var request UpdateUserRequestObject
request.UserId = userId request.UserId = userId
request.Params = params
var body UpdateUserJSONRequestBody var body UpdateUserJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil { if err := ctx.ShouldBindJSON(&body); err != nil {

View file

@ -1,4 +1,10 @@
cursor: cursor:
$ref: "./cursor.yaml" $ref: "./cursor.yaml"
title_sort: title_sort:
$ref: "./title_sort.yaml" $ref: "./title_sort.yaml"
accessToken:
$ref: "./access_token.yaml"
csrfToken:
$ref: "./xsrf_token_cookie.yaml"
csrfTokenHeader:
$ref: "./xsrf_token_header.yaml"

View file

@ -0,0 +1,9 @@
name: access_token
in: cookie
required: true
schema:
type: string
format: jwt
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y"
description: |
JWT access token.

View file

@ -0,0 +1,11 @@
name: xsrf_token
in: cookie
required: true
schema:
type: string
pattern: "^[a-zA-Z0-9_-]{32,64}$"
example: "abc123def456ghi789jkl012mno345pqr"
description: |
Anti-CSRF token (Double Submit Cookie pattern).
Stored in non-HttpOnly cookie, readable by JavaScript.
Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE).

View file

@ -0,0 +1,10 @@
name: X-XSRF-TOKEN
in: header
required: true
schema:
type: string
pattern: "^[a-zA-Z0-9_-]{32,64}$"
description: |
Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
Required for all state-changing requests (POST/PUT/PATCH/DELETE).
example: "abc123def456ghi789jkl012mno345pqr"

View file

@ -1,5 +1,7 @@
get: get:
summary: Get title description summary: Get title description
security:
- JwtAuthCookies: []
operationId: getTitle operationId: getTitle
parameters: parameters:
- in: path - in: path

View file

@ -1,6 +1,8 @@
get: get:
summary: Get user title summary: Get user title
operationId: getUserTitle operationId: getUserTitle
security:
- JwtAuthCookies: []
parameters: parameters:
- in: path - in: path
name: user_id name: user_id
@ -34,7 +36,10 @@ patch:
summary: Update a usertitle summary: Update a usertitle
description: User updating title list of watched description: User updating title list of watched
operationId: updateUserTitle operationId: updateUserTitle
security:
- JwtAuthCookies: []
parameters: parameters:
- $ref: '../parameters/xsrf_token_header.yaml'
- in: path - in: path
name: user_id name: user_id
required: true required: true
@ -81,7 +86,10 @@ delete:
summary: Delete a usertitle summary: Delete a usertitle
description: User deleting title from list of watched description: User deleting title from list of watched
operationId: deleteUserTitle operationId: deleteUserTitle
security:
- JwtAuthCookies: []
parameters: parameters:
- $ref: '../parameters/xsrf_token_header.yaml'
- in: path - in: path
name: user_id name: user_id
required: true required: true

View file

@ -1,6 +1,8 @@
get: get:
summary: Get user info summary: Get user info
operationId: getUsersId operationId: getUsersId
security:
- JwtAuthCookies: []
parameters: parameters:
- in: path - in: path
name: user_id name: user_id
@ -28,12 +30,15 @@ get:
patch: patch:
summary: Partially update a user account summary: Partially update a user account
security:
- JwtAuthCookies: []
description: | description: |
Update selected user profile fields (excluding password). Update selected user profile fields (excluding password).
Password updates must be done via the dedicated auth-service (`/auth/`). Password updates must be done via the dedicated auth-service (`/auth/`).
Fields not provided in the request body remain unchanged. Fields not provided in the request body remain unchanged.
operationId: updateUser operationId: updateUser
parameters: parameters:
- $ref: '../parameters/xsrf_token_header.yaml'
- name: user_id - name: user_id
in: path in: path
required: true required: true

7
api/schemas/JWTAuth.yaml Normal file
View file

@ -0,0 +1,7 @@
# type: apiKey
# in: cookie
# name: access_token
# scheme: bearer
# bearerFormat: JWT
# description: |
# JWT access token sent in `Cookie: access_token=...`.

View file

@ -24,3 +24,5 @@ User:
$ref: "./User.yaml" $ref: "./User.yaml"
UserTitle: UserTitle:
$ref: "./UserTitle.yaml" $ref: "./UserTitle.yaml"
# JwtAuth:
# $ref: "./JWTAuth.yaml"

View file

@ -7,7 +7,7 @@ servers:
- url: /auth - url: /auth
paths: paths:
/auth/sign-up: /sign-up:
post: post:
summary: Sign up a new user summary: Sign up a new user
tags: [Auth] tags: [Auth]
@ -38,7 +38,7 @@ paths:
type: integer type: integer
format: int64 format: int64
/auth/sign-in: /sign-in:
post: post:
summary: Sign in a user and return JWT summary: Sign in a user and return JWT
tags: [Auth] tags: [Auth]

View file

@ -11,6 +11,7 @@ import (
oapi "nyanimedb/api" oapi "nyanimedb/api"
handlers "nyanimedb/modules/backend/handlers" handlers "nyanimedb/modules/backend/handlers"
middleware "nyanimedb/modules/backend/middlewares"
"nyanimedb/modules/backend/rmq" "nyanimedb/modules/backend/rmq"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
@ -45,6 +46,8 @@ func main() {
r := gin.Default() r := gin.Default()
r.Use(middleware.CSRFMiddleware())
// jwt middle will be here
queries := sqlc.New(pool) queries := sqlc.New(pool)
// === RabbitMQ setup === // === RabbitMQ setup ===
@ -63,7 +66,6 @@ func main() {
rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second)
server := handlers.NewServer(queries, publisher, rpcClient) server := handlers.NewServer(queries, publisher, rpcClient)
// r.LoadHTMLGlob("templates/*")
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production

View file

@ -0,0 +1,70 @@
package middleware
import (
"crypto/subtle"
"net/http"
"github.com/gin-gonic/gin"
)
// CSRFMiddleware для Gin
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Пропускаем безопасные методы
if !isStateChangingMethod(c.Request.Method) {
c.Next()
return
}
// 1. Получаем токен из заголовка
headerToken := c.GetHeader("X-XSRF-TOKEN")
if headerToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "missing X-XSRF-TOKEN header",
})
return
}
// 2. Получаем токен из cookie
cookie, err := c.Cookie("xsrf_token")
if err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "missing xsrf_token cookie",
})
return
}
// 3. Безопасное сравнение
if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie)) != 1 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "CSRF token mismatch",
})
return
}
// 4. Опционально: сохраняем токен в контексте
c.Set("csrf_token", headerToken)
c.Next()
}
}
func isStateChangingMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
// CSRFTokenFromGin извлекает токен из Gin context
func CSRFTokenFromGin(c *gin.Context) (string, bool) {
token, exists := c.Get("xsrf_token")
if !exists {
return "", false
}
if s, ok := token.(string); ok {
return s, true
}
return "", false
}

View file

@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"
@ -1868,6 +1869,18 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1890,7 +1903,6 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@ -2524,7 +2536,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -3260,6 +3271,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -4068,6 +4088,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz",
"integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==",
"license": "MIT",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.6",
"hoist-non-react-statics": "^3.3.2",
"universal-cookie": "^8.0.0"
},
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
@ -4081,6 +4115,12 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -4481,6 +4521,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/universal-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz",
"integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
}
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",

View file

@ -15,6 +15,7 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"

View file

@ -7,6 +7,9 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI'; export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI';
export type { accessToken } from './models/accessToken';
export type { csrfToken } from './models/csrfToken';
export type { csrfTokenHeader } from './models/csrfTokenHeader';
export type { cursor } from './models/cursor'; export type { cursor } from './models/cursor';
export type { CursorObj } from './models/CursorObj'; export type { CursorObj } from './models/CursorObj';
export type { Image } from './models/Image'; export type { Image } from './models/Image';

View file

@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* JWT access token.
*
*/
export type accessToken = string;

View file

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Anti-CSRF token (Double Submit Cookie pattern).
* Stored in non-HttpOnly cookie, readable by JavaScript.
* Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE).
*
*/
export type csrfToken = string;

View file

@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
* Required for all state-changing requests (POST/PUT/PATCH/DELETE).
*
*/
export type csrfTokenHeader = string;

View file

@ -135,12 +135,16 @@ export class DefaultService {
* Password updates must be done via the dedicated auth-service (`/auth/`). * Password updates must be done via the dedicated auth-service (`/auth/`).
* Fields not provided in the request body remain unchanged. * Fields not provided in the request body remain unchanged.
* *
* @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
* Required for all state-changing requests (POST/PUT/PATCH/DELETE).
*
* @param userId User ID (primary key) * @param userId User ID (primary key)
* @param requestBody * @param requestBody
* @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields).
* @throws ApiError * @throws ApiError
*/ */
public static updateUser( public static updateUser(
xXsrfToken: string,
userId: number, userId: number,
requestBody: { requestBody: {
/** /**
@ -171,6 +175,9 @@ export class DefaultService {
path: { path: {
'user_id': userId, 'user_id': userId,
}, },
headers: {
'X-XSRF-TOKEN': xXsrfToken,
},
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
@ -309,6 +316,9 @@ export class DefaultService {
/** /**
* Update a usertitle * Update a usertitle
* User updating title list of watched * User updating title list of watched
* @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
* Required for all state-changing requests (POST/PUT/PATCH/DELETE).
*
* @param userId * @param userId
* @param titleId * @param titleId
* @param requestBody * @param requestBody
@ -316,6 +326,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public static updateUserTitle( public static updateUserTitle(
xXsrfToken: string,
userId: number, userId: number,
titleId: number, titleId: number,
requestBody: { requestBody: {
@ -330,6 +341,9 @@ export class DefaultService {
'user_id': userId, 'user_id': userId,
'title_id': titleId, 'title_id': titleId,
}, },
headers: {
'X-XSRF-TOKEN': xXsrfToken,
},
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
@ -344,12 +358,16 @@ export class DefaultService {
/** /**
* Delete a usertitle * Delete a usertitle
* User deleting title from list of watched * User deleting title from list of watched
* @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
* Required for all state-changing requests (POST/PUT/PATCH/DELETE).
*
* @param userId * @param userId
* @param titleId * @param titleId
* @returns any Title successfully deleted * @returns any Title successfully deleted
* @throws ApiError * @throws ApiError
*/ */
public static deleteUserTitle( public static deleteUserTitle(
xXsrfToken: string,
userId: number, userId: number,
titleId: number, titleId: number,
): CancelablePromise<any> { ): CancelablePromise<any> {
@ -360,6 +378,9 @@ export class DefaultService {
'user_id': userId, 'user_id': userId,
'title_id': titleId, 'title_id': titleId,
}, },
headers: {
'X-XSRF-TOKEN': xXsrfToken,
},
errors: { errors: {
401: `Unauthorized — missing or invalid auth token`, 401: `Unauthorized — missing or invalid auth token`,
403: `Forbidden — user not allowed to delete title`, 403: `Forbidden — user not allowed to delete title`,

View file

@ -12,19 +12,17 @@ export class AuthService {
* @returns any Sign-up result * @returns any Sign-up result
* @throws ApiError * @throws ApiError
*/ */
public static postAuthSignUp( public static postSignUp(
requestBody: { requestBody: {
nickname: string; nickname: string;
pass: string; pass: string;
}, },
): CancelablePromise<{ ): CancelablePromise<{
success?: boolean; user_id: number;
error?: string | null;
user_id?: string | null;
}> { }> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/auth/sign-up', url: '/sign-up',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
}); });
@ -35,19 +33,18 @@ export class AuthService {
* @returns any Sign-in result with JWT * @returns any Sign-in result with JWT
* @throws ApiError * @throws ApiError
*/ */
public static postAuthSignIn( public static postSignIn(
requestBody: { requestBody: {
nickname: string; nickname: string;
pass: string; pass: string;
}, },
): CancelablePromise<{ ): CancelablePromise<{
error?: string | null; user_id: number;
user_id?: string | null; user_name: string;
user_name?: string | null;
}> { }> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/auth/sign-in', url: '/sign-in',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {

View file

@ -1,6 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DefaultService } from "../../api"; import { DefaultService } from "../../api";
import type { UserTitleStatus } from "../../api"; import type { UserTitleStatus } from "../../api";
import { useCookies } from 'react-cookie';
import { import {
ClockIcon, ClockIcon,
CheckCircleIcon, CheckCircleIcon,
@ -17,6 +19,9 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s
]; ];
export function TitleStatusControls({ titleId }: { titleId: number }) { export function TitleStatusControls({ titleId }: { titleId: number }) {
const [cookies] = useCookies(['xsrf_token']);
const xsrfToken = cookies['xsrf_token'] || null;
const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -41,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) {
try { try {
// 1) Если кликнули на текущий статус — DELETE // 1) Если кликнули на текущий статус — DELETE
if (currentStatus === status) { if (currentStatus === status) {
await DefaultService.deleteUserTitle(userId, titleId); await DefaultService.deleteUserTitle(xsrfToken, userId, titleId);
setCurrentStatus(null); setCurrentStatus(null);
return; return;
} }
@ -56,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) {
setCurrentStatus(added.status); setCurrentStatus(added.status);
} else { } else {
// уже есть запись — PATCH // уже есть запись — PATCH
const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status });
setCurrentStatus(updated.status); setCurrentStatus(updated.status);
} }
} finally { } finally {

View file

@ -17,23 +17,23 @@ export const LoginPage: React.FC = () => {
try { try {
if (isLogin) { if (isLogin) {
const res = await AuthService.postAuthSignIn({ nickname, pass: password }); const res = await AuthService.postSignIn({ nickname, pass: password });
if (res.user_id && res.user_name) { if (res.user_id && res.user_name) {
// Сохраняем user_id и username в localStorage // Сохраняем user_id и username в localStorage
localStorage.setItem("userId", res.user_id); localStorage.setItem("userId", res.user_id.toString());
localStorage.setItem("username", res.user_name); localStorage.setItem("username", res.user_name);
navigate("/profile"); // редирект на профиль navigate("/profile"); // редирект на профиль
} else { } else {
setError(res.error || "Login failed"); setError("Login failed");
} }
} else { } else {
// SignUp оставляем без сохранения данных // SignUp оставляем без сохранения данных
const res = await AuthService.postAuthSignUp({ nickname, pass: password }); const res = await AuthService.postSignUp({ nickname, pass: password });
if (res.user_id) { if (res.user_id) {
setIsLogin(true); // переключаемся на login после регистрации setIsLogin(true); // переключаемся на login после регистрации
} else { } else {
setError(res.error || "Sign up failed"); setError("Sign up failed");
} }
} }
} catch (err: any) { } catch (err: any) {