Compare commits

...

23 commits

Author SHA1 Message Date
6995ce58f6 feat: csrf tokens handling 2025-12-04 06:13:03 +03:00
ab29c33f5b feat: now back wait for RMQ answer
All checks were successful
Build and Deploy Go App / build (push) Successful in 6m20s
Build and Deploy Go App / deploy (push) Successful in 1m1s
2025-11-30 04:02:28 +03:00
a29aefbe97 fix
All checks were successful
Build and Deploy Go App / build (push) Successful in 5m53s
Build and Deploy Go App / deploy (push) Successful in 32s
2025-11-30 03:20:52 +03:00
1308e265a6 Merge branch 'dev' into dev-ars
All checks were successful
Build and Deploy Go App / build (push) Successful in 8m39s
Build and Deploy Go App / deploy (push) Successful in 35s
2025-11-30 03:01:23 +03:00
77a63a1c74 feat: rabbitMQ is now calling from seatchtitles 2025-11-30 02:57:11 +03:00
1756d61da4 lib for rabbitMQ 2025-11-30 00:51:38 +03:00
f71c1f4f08
feat: added rabbitmq
All checks were successful
Build and Deploy Go App / build (push) Successful in 5m19s
Build and Deploy Go App / deploy (push) Successful in 53s
2025-11-28 11:43:10 +03:00
f843c23e57
Merge branch 'front' into dev
All checks were successful
Build and Deploy Go App / build (push) Successful in 5m57s
Build and Deploy Go App / deploy (push) Successful in 36s
2025-11-27 16:28:16 +03:00
37cdc32d5d
fix: fix GetUserTitleByID 2025-11-27 16:28:09 +03:00
8a3e14a5e5
feat: TitleStatusControls 2025-11-27 16:26:03 +03:00
3f0456ba01
cicd: updated
All checks were successful
Build and Deploy Go App / build (push) Successful in 7m21s
Build and Deploy Go App / deploy (push) Successful in 29s
2025-11-27 14:09:13 +03:00
13342d5613
cicd: updated
Some checks are pending
Build and Deploy Go App / deploy (push) Blocked by required conditions
Build and Deploy Go App / build (push) Successful in 7m15s
2025-11-27 13:18:19 +03:00
1c9de1c402
Merge branch 'front' into dev
Some checks failed
Build and Deploy Go App / build (push) Has been cancelled
Build and Deploy Go App / deploy (push) Has been cancelled
2025-11-27 12:15:26 +03:00
497e4039ec
Merge branch 'auth' into dev
All checks were successful
Build and Deploy Go App / build (push) Successful in 6m40s
Build and Deploy Go App / deploy (push) Successful in 35s
2025-11-27 12:05:59 +03:00
f1e61aee2d
Merge branch 'dev-ars' into dev
Some checks failed
Build and Deploy Go App / build (push) Failing after 2m53s
Build and Deploy Go App / deploy (push) Has been skipped
2025-11-27 12:00:57 +03:00
ad1c567b42
feat: added GetUserTitle route 2025-11-27 11:59:49 +03:00
de22dbfb50
feat: title cards linked to title pages 2025-11-27 10:01:52 +03:00
98178731b9
refact: UsersIdPage -> UserPage 2025-11-27 09:51:49 +03:00
9338c65040
chore: updated sqlc generated code
All checks were successful
Build and Deploy Go App / build (push) Successful in 6m39s
Build and Deploy Go App / deploy (push) Successful in 27s
2025-11-27 09:44:41 +03:00
40e0b14f2a
feat: use postgres to fetch and store user info 2025-11-27 09:44:02 +03:00
3528ea7d34
cicd: removed go mod tidy for go builds 2025-11-27 09:42:30 +03:00
6cbf0afb33
feat: use postgres to fetch and store user info 2025-11-27 09:42:05 +03:00
79e8ece948
cicd: removed go mod tidy for go builds 2025-11-27 06:42:07 +03:00
43 changed files with 1797 additions and 1015 deletions

View file

@ -18,14 +18,10 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '^1.25'
check-latest: false
cache-dependency-path: |
go.sum
- name: Build backend
run: |
cd modules/backend
go mod tidy
go build -o nyanimedb .
tar -czvf nyanimedb-backend.tar.gz nyanimedb
@ -38,7 +34,6 @@ jobs:
- name: Build auth
run: |
cd modules/auth
go mod tidy
go build -o auth .
tar -czvf nyanimedb-auth.tar.gz auth
@ -106,7 +101,7 @@ jobs:
tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
deploy:
runs-on: self-hosted
runs-on: debian-test
needs: build
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}

View file

@ -120,6 +120,8 @@ paths:
description: Title not found
'500':
description: Unknown server error
security:
- JwtAuthCookies: []
'/users/{user_id}':
get:
operationId: getUsersId
@ -156,6 +158,8 @@ paths:
Password updates must be done via the dedicated auth-service (`/auth/`).
Fields not provided in the request body remain unchanged.
parameters:
- $ref: '#/components/parameters/accessToken'
- $ref: '#/components/parameters/csrfToken'
- name: user_id
in: path
description: User ID (primary key)
@ -223,8 +227,11 @@ paths:
description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)'
'500':
description: Unknown server error
security:
- JwtAuthCookies: []
'/users/{user_id}/titles':
get:
operationId: getUserTitles
summary: Get user titles
parameters:
- $ref: '#/components/parameters/cursor'
@ -365,6 +372,38 @@ paths:
description: Conflict — title already assigned to user (if applicable)
'500':
description: Internal server error
'/users/{user_id}/titles/{title_id}':
get:
operationId: getUserTitle
summary: Get user title
parameters:
- name: user_id
in: path
required: true
schema:
type: integer
format: int64
- name: title_id
in: path
required: true
schema:
type: integer
format: int64
responses:
'200':
description: User titles
content:
application/json:
schema:
$ref: '#/components/schemas/UserTitleMini'
'204':
description: No user title found
'400':
description: Request params are not correct
'404':
description: User or title not found
'500':
description: Unknown server error
patch:
operationId: updateUserTitle
summary: Update a usertitle
@ -372,12 +411,16 @@ paths:
parameters:
- name: user_id
in: path
description: ID of the user to assign the title to
required: true
schema:
type: integer
format: int64
example: 123
- name: title_id
in: path
required: true
schema:
type: integer
format: int64
requestBody:
required: true
content:
@ -385,16 +428,11 @@ paths:
schema:
type: object
properties:
title_id:
type: integer
format: int64
status:
$ref: '#/components/schemas/UserTitleStatus'
rate:
type: integer
format: int32
required:
- title_id
responses:
'200':
description: Title successfully updated
@ -419,13 +457,12 @@ paths:
parameters:
- name: user_id
in: path
description: ID of the user to assign the title to
required: true
schema:
type: integer
format: int64
- name: title_id
in: query
in: path
required: true
schema:
type: integer
@ -443,6 +480,39 @@ paths:
description: Internal server error
components:
parameters:
accessToken:
name: access_token
in: cookie
required: true
schema:
type: string
format: jwt
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y
description: |
JWT access token.
csrfToken:
name: XSRF-TOKEN
in: cookie
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9_-]{32,64}$'
example: abc123def456ghi789jkl012mno345pqr
description: |
Anti-CSRF token (Double Submit Cookie pattern).
Stored in non-HttpOnly cookie, readable by JavaScript.
Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE).
csrfTokenHeader:
name: X-XSRF-TOKEN
in: header
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9_-]{32,64}$'
description: |
Anti-CSRF token. Must match the `XSRF-TOKEN` cookie.
Required for all state-changing requests (POST/PUT/PATCH/DELETE).
example: abc123def456ghi789jkl012mno345pqr
cursor:
in: query
name: cursor

View file

@ -16,6 +16,10 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
const (
JwtAuthCookiesScopes = "JwtAuthCookies.Scopes"
)
// Defines values for ReleaseSeason.
const (
Fall ReleaseSeason = "fall"
@ -170,6 +174,12 @@ type UserTitleMini struct {
// UserTitleStatus User's title status
type UserTitleStatus string
// AccessToken defines model for accessToken.
type AccessToken = string
// CsrfToken defines model for csrfToken.
type CsrfToken = string
// Cursor defines model for cursor.
type Cursor = string
@ -219,13 +229,19 @@ type UpdateUserJSONBody struct {
UserDesc *string `json:"user_desc,omitempty"`
}
// DeleteUserTitleParams defines parameters for DeleteUserTitle.
type DeleteUserTitleParams struct {
TitleId int64 `form:"title_id" json:"title_id"`
// 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"`
}
// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles.
type GetUsersUserIdTitlesParams struct {
// GetUserTitlesParams defines parameters for GetUserTitles.
type GetUserTitlesParams struct {
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"`
SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"`
@ -242,15 +258,6 @@ type GetUsersUserIdTitlesParams struct {
Fields *string `form:"fields,omitempty" json:"fields,omitempty"`
}
// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle.
type UpdateUserTitleJSONBody struct {
Rate *int32 `json:"rate,omitempty"`
// Status User's title status
Status *UserTitleStatus `json:"status,omitempty"`
TitleId int64 `json:"title_id"`
}
// AddUserTitleJSONBody defines parameters for AddUserTitle.
type AddUserTitleJSONBody struct {
Rate *int32 `json:"rate,omitempty"`
@ -260,15 +267,23 @@ type AddUserTitleJSONBody struct {
TitleId int64 `json:"title_id"`
}
// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle.
type UpdateUserTitleJSONBody struct {
Rate *int32 `json:"rate,omitempty"`
// Status User's title status
Status *UserTitleStatus `json:"status,omitempty"`
}
// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType.
type UpdateUserJSONRequestBody UpdateUserJSONBody
// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType.
type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody
// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType.
type AddUserTitleJSONRequestBody AddUserTitleJSONBody
// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType.
type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody
// ServerInterface represents all server handlers.
type ServerInterface interface {
// Get titles
@ -282,19 +297,22 @@ type ServerInterface interface {
GetUsersId(c *gin.Context, userId string, params GetUsersIdParams)
// Partially update a user account
// (PATCH /users/{user_id})
UpdateUser(c *gin.Context, userId int64)
// Delete a usertitle
// (DELETE /users/{user_id}/titles)
DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams)
UpdateUser(c *gin.Context, userId int64, params UpdateUserParams)
// Get user titles
// (GET /users/{user_id}/titles)
GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams)
// Update a usertitle
// (PATCH /users/{user_id}/titles)
UpdateUserTitle(c *gin.Context, userId int64)
GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams)
// Add a title to a user
// (POST /users/{user_id}/titles)
AddUserTitle(c *gin.Context, userId int64)
// Delete a usertitle
// (DELETE /users/{user_id}/titles/{title_id})
DeleteUserTitle(c *gin.Context, userId int64, titleId int64)
// Get user title
// (GET /users/{user_id}/titles/{title_id})
GetUserTitle(c *gin.Context, userId int64, titleId int64)
// Update a usertitle
// (PATCH /users/{user_id}/titles/{title_id})
UpdateUserTitle(c *gin.Context, userId int64, titleId int64)
}
// ServerInterfaceWrapper converts contexts to parameters.
@ -434,6 +452,8 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) {
return
}
c.Set(JwtAuthCookiesScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context
var params GetTitleParams
@ -504,46 +524,45 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) {
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.UpdateUser(c, userId)
}
// DeleteUserTitle operation middleware
func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) {
var err error
// ------------- Path parameter "user_id" -------------
var userId int64
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest)
return
}
c.Set(JwtAuthCookiesScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context
var params DeleteUserTitleParams
var params UpdateUserParams
// ------------- Required query parameter "title_id" -------------
{
var cookie string
if paramValue := c.Query("title_id"); paramValue != "" {
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 title_id is required, but not found"), http.StatusBadRequest)
return
} else {
siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest)
return
}
}
err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), &params.TitleId)
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), 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 {
@ -553,11 +572,11 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) {
}
}
siw.Handler.DeleteUserTitle(c, userId, params)
siw.Handler.UpdateUser(c, userId, params)
}
// GetUsersUserIdTitles operation middleware
func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) {
// GetUserTitles operation middleware
func (siw *ServerInterfaceWrapper) GetUserTitles(c *gin.Context) {
var err error
@ -571,7 +590,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) {
}
// Parameter object where we will unmarshal all parameters from the context
var params GetUsersUserIdTitlesParams
var params GetUserTitlesParams
// ------------- Optional query parameter "cursor" -------------
@ -676,31 +695,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) {
}
}
siw.Handler.GetUsersUserIdTitles(c, userId, params)
}
// UpdateUserTitle operation middleware
func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) {
var err error
// ------------- Path parameter "user_id" -------------
var userId int64
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest)
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.UpdateUserTitle(c, userId)
siw.Handler.GetUserTitles(c, userId, params)
}
// AddUserTitle operation middleware
@ -727,6 +722,105 @@ func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) {
siw.Handler.AddUserTitle(c, userId)
}
// DeleteUserTitle operation middleware
func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) {
var err error
// ------------- Path parameter "user_id" -------------
var userId int64
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest)
return
}
// ------------- Path parameter "title_id" -------------
var titleId int64
err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest)
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.DeleteUserTitle(c, userId, titleId)
}
// GetUserTitle operation middleware
func (siw *ServerInterfaceWrapper) GetUserTitle(c *gin.Context) {
var err error
// ------------- Path parameter "user_id" -------------
var userId int64
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest)
return
}
// ------------- Path parameter "title_id" -------------
var titleId int64
err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest)
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.GetUserTitle(c, userId, titleId)
}
// UpdateUserTitle operation middleware
func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) {
var err error
// ------------- Path parameter "user_id" -------------
var userId int64
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest)
return
}
// ------------- Path parameter "title_id" -------------
var titleId int64
err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest)
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.UpdateUserTitle(c, userId, titleId)
}
// GinServerOptions provides options for the Gin server.
type GinServerOptions struct {
BaseURL string
@ -758,10 +852,11 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle)
router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId)
router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser)
router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle)
router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles)
router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle)
router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles)
router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle)
router.DELETE(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.DeleteUserTitle)
router.GET(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.GetUserTitle)
router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle)
}
type GetTitlesRequestObject struct {
@ -904,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri
type UpdateUserRequestObject struct {
UserId int64 `json:"user_id"`
Params UpdateUserParams
Body *UpdateUserJSONRequestBody
}
@ -976,162 +1072,55 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri
return nil
}
type DeleteUserTitleRequestObject struct {
UserId int64 `json:"user_id"`
Params DeleteUserTitleParams
}
type DeleteUserTitleResponseObject interface {
VisitDeleteUserTitleResponse(w http.ResponseWriter) error
}
type DeleteUserTitle200Response struct {
}
func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(200)
return nil
}
type DeleteUserTitle401Response struct {
}
func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(401)
return nil
}
type DeleteUserTitle403Response struct {
}
func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(403)
return nil
}
type DeleteUserTitle404Response struct {
}
func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type DeleteUserTitle500Response struct {
}
func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
type GetUsersUserIdTitlesRequestObject struct {
type GetUserTitlesRequestObject struct {
UserId string `json:"user_id"`
Params GetUsersUserIdTitlesParams
Params GetUserTitlesParams
}
type GetUsersUserIdTitlesResponseObject interface {
VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error
type GetUserTitlesResponseObject interface {
VisitGetUserTitlesResponse(w http.ResponseWriter) error
}
type GetUsersUserIdTitles200JSONResponse struct {
type GetUserTitles200JSONResponse struct {
Cursor CursorObj `json:"cursor"`
Data []UserTitle `json:"data"`
}
func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error {
func (response GetUserTitles200JSONResponse) VisitGetUserTitlesResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetUsersUserIdTitles204Response struct {
type GetUserTitles204Response struct {
}
func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error {
func (response GetUserTitles204Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type GetUsersUserIdTitles400Response struct {
type GetUserTitles400Response struct {
}
func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error {
func (response GetUserTitles400Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type GetUsersUserIdTitles404Response struct {
type GetUserTitles404Response struct {
}
func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error {
func (response GetUserTitles404Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type GetUsersUserIdTitles500Response struct {
type GetUserTitles500Response struct {
}
func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
type UpdateUserTitleRequestObject struct {
UserId int64 `json:"user_id"`
Body *UpdateUserTitleJSONRequestBody
}
type UpdateUserTitleResponseObject interface {
VisitUpdateUserTitleResponse(w http.ResponseWriter) error
}
type UpdateUserTitle200JSONResponse UserTitleMini
func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type UpdateUserTitle400Response struct {
}
func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type UpdateUserTitle401Response struct {
}
func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(401)
return nil
}
type UpdateUserTitle403Response struct {
}
func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(403)
return nil
}
type UpdateUserTitle404Response struct {
}
func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type UpdateUserTitle500Response struct {
}
func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
func (response GetUserTitles500Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
@ -1202,6 +1191,164 @@ func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.Respons
return nil
}
type DeleteUserTitleRequestObject struct {
UserId int64 `json:"user_id"`
TitleId int64 `json:"title_id"`
}
type DeleteUserTitleResponseObject interface {
VisitDeleteUserTitleResponse(w http.ResponseWriter) error
}
type DeleteUserTitle200Response struct {
}
func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(200)
return nil
}
type DeleteUserTitle401Response struct {
}
func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(401)
return nil
}
type DeleteUserTitle403Response struct {
}
func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(403)
return nil
}
type DeleteUserTitle404Response struct {
}
func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type DeleteUserTitle500Response struct {
}
func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
type GetUserTitleRequestObject struct {
UserId int64 `json:"user_id"`
TitleId int64 `json:"title_id"`
}
type GetUserTitleResponseObject interface {
VisitGetUserTitleResponse(w http.ResponseWriter) error
}
type GetUserTitle200JSONResponse UserTitleMini
func (response GetUserTitle200JSONResponse) VisitGetUserTitleResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetUserTitle204Response struct {
}
func (response GetUserTitle204Response) VisitGetUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type GetUserTitle400Response struct {
}
func (response GetUserTitle400Response) VisitGetUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type GetUserTitle404Response struct {
}
func (response GetUserTitle404Response) VisitGetUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type GetUserTitle500Response struct {
}
func (response GetUserTitle500Response) VisitGetUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
type UpdateUserTitleRequestObject struct {
UserId int64 `json:"user_id"`
TitleId int64 `json:"title_id"`
Body *UpdateUserTitleJSONRequestBody
}
type UpdateUserTitleResponseObject interface {
VisitUpdateUserTitleResponse(w http.ResponseWriter) error
}
type UpdateUserTitle200JSONResponse UserTitleMini
func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type UpdateUserTitle400Response struct {
}
func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(400)
return nil
}
type UpdateUserTitle401Response struct {
}
func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(401)
return nil
}
type UpdateUserTitle403Response struct {
}
func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(403)
return nil
}
type UpdateUserTitle404Response struct {
}
func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404)
return nil
}
type UpdateUserTitle500Response struct {
}
func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(500)
return nil
}
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// Get titles
@ -1216,18 +1363,21 @@ type StrictServerInterface interface {
// Partially update a user account
// (PATCH /users/{user_id})
UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error)
// Delete a usertitle
// (DELETE /users/{user_id}/titles)
DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error)
// Get user titles
// (GET /users/{user_id}/titles)
GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error)
// Update a usertitle
// (PATCH /users/{user_id}/titles)
UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error)
GetUserTitles(ctx context.Context, request GetUserTitlesRequestObject) (GetUserTitlesResponseObject, error)
// Add a title to a user
// (POST /users/{user_id}/titles)
AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error)
// Delete a usertitle
// (DELETE /users/{user_id}/titles/{title_id})
DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error)
// Get user title
// (GET /users/{user_id}/titles/{title_id})
GetUserTitle(ctx context.Context, request GetUserTitleRequestObject) (GetUserTitleResponseObject, error)
// Update a usertitle
// (PATCH /users/{user_id}/titles/{title_id})
UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error)
}
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
@ -1326,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU
}
// UpdateUser operation middleware
func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) {
func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) {
var request UpdateUserRequestObject
request.UserId = userId
request.Params = params
var body UpdateUserJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
@ -1360,18 +1511,18 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) {
}
}
// DeleteUserTitle operation middleware
func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) {
var request DeleteUserTitleRequestObject
// GetUserTitles operation middleware
func (sh *strictHandler) GetUserTitles(ctx *gin.Context, userId string, params GetUserTitlesParams) {
var request GetUserTitlesRequestObject
request.UserId = userId
request.Params = params
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject))
return sh.ssi.GetUserTitles(ctx, request.(GetUserTitlesRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "DeleteUserTitle")
handler = middleware(handler, "GetUserTitles")
}
response, err := handler(ctx, request)
@ -1379,71 +1530,8 @@ func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok {
if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// GetUsersUserIdTitles operation middleware
func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) {
var request GetUsersUserIdTitlesRequestObject
request.UserId = userId
request.Params = params
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetUsersUserIdTitles")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok {
if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// UpdateUserTitle operation middleware
func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) {
var request UpdateUserTitleRequestObject
request.UserId = userId
var body UpdateUserTitleJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "UpdateUserTitle")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok {
if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil {
} else if validResponse, ok := response.(GetUserTitlesResponseObject); ok {
if err := validResponse.VisitGetUserTitlesResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
@ -1485,3 +1573,95 @@ func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// DeleteUserTitle operation middleware
func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, titleId int64) {
var request DeleteUserTitleRequestObject
request.UserId = userId
request.TitleId = titleId
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "DeleteUserTitle")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok {
if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// GetUserTitle operation middleware
func (sh *strictHandler) GetUserTitle(ctx *gin.Context, userId int64, titleId int64) {
var request GetUserTitleRequestObject
request.UserId = userId
request.TitleId = titleId
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetUserTitle(ctx, request.(GetUserTitleRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetUserTitle")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetUserTitleResponseObject); ok {
if err := validResponse.VisitGetUserTitleResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// UpdateUserTitle operation middleware
func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64, titleId int64) {
var request UpdateUserTitleRequestObject
request.UserId = userId
request.TitleId = titleId
var body UpdateUserTitleJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "UpdateUserTitle")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok {
if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}

View file

@ -15,6 +15,8 @@ paths:
$ref: "./paths/users-id.yaml"
/users/{user_id}/titles:
$ref: "./paths/users-id-titles.yaml"
/users/{user_id}/titles/{title_id}:
$ref: "./paths/users-id-titles-id.yaml"
components:
parameters:

View file

@ -1,4 +1,10 @@
cursor:
$ref: "./cursor.yaml"
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:
summary: Get title description
security:
- JwtAuthCookies: []
operationId: getTitle
parameters:
- in: path

View file

@ -0,0 +1,107 @@
get:
summary: Get user title
operationId: getUserTitle
parameters:
- in: path
name: user_id
required: true
schema:
type: integer
format: int64
- in: path
name: title_id
required: true
schema:
type: integer
format: int64
responses:
'200':
description: User titles
content:
application/json:
schema:
$ref: '../schemas/UserTitleMini.yaml'
'204':
description: No user title found
'400':
description: Request params are not correct
'404':
description: User or title not found
'500':
description: Unknown server error
patch:
summary: Update a usertitle
description: User updating title list of watched
operationId: updateUserTitle
parameters:
- in: path
name: user_id
required: true
schema:
type: integer
format: int64
- in: path
name: title_id
required: true
schema:
type: integer
format: int64
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
$ref: '../schemas/enums/UserTitleStatus.yaml'
rate:
type: integer
format: int32
responses:
'200':
description: Title successfully updated
content:
application/json:
schema:
$ref: '../schemas/UserTitleMini.yaml'
'400':
description: Invalid request body (missing fields, invalid types, etc.)
'401':
description: Unauthorized — missing or invalid auth token
'403':
description: Forbidden — user not allowed to update title
'404':
description: User or Title not found
'500':
description: Internal server error
delete:
summary: Delete a usertitle
description: User deleting title from list of watched
operationId: deleteUserTitle
parameters:
- in: path
name: user_id
required: true
schema:
type: integer
format: int64
- in: path
name: title_id
required: true
schema:
type: integer
format: int64
responses:
'200':
description: Title successfully deleted
'401':
description: Unauthorized — missing or invalid auth token
'403':
description: Forbidden — user not allowed to delete title
'404':
description: User or Title not found
'500':
description: Internal server error

View file

@ -1,5 +1,6 @@
get:
summary: Get user titles
operationId: getUserTitles
parameters:
- $ref: '../parameters/cursor.yaml'
- $ref: "../parameters/title_sort.yaml"
@ -138,88 +139,5 @@ post:
description: User or Title not found
'409':
description: Conflict — title already assigned to user (if applicable)
'500':
description: Internal server error
patch:
summary: Update a usertitle
description: User updating title list of watched
operationId: updateUserTitle
parameters:
- name: user_id
in: path
required: true
schema:
type: integer
format: int64
description: ID of the user to assign the title to
example: 123
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- title_id
properties:
title_id:
type: integer
format: int64
status:
$ref: '../schemas/enums/UserTitleStatus.yaml'
rate:
type: integer
format: int32
responses:
'200':
description: Title successfully updated
content:
application/json:
schema:
$ref: '../schemas/UserTitleMini.yaml'
'400':
description: Invalid request body (missing fields, invalid types, etc.)
'401':
description: Unauthorized — missing or invalid auth token
'403':
description: Forbidden — user not allowed to update title
'404':
description: User or Title not found
'500':
description: Internal server error
delete:
summary: Delete a usertitle
description: User deleting title from list of watched
operationId: deleteUserTitle
parameters:
- name: user_id
in: path
required: true
schema:
type: integer
format: int64
description: ID of the user to assign the title to
- in: query
name: title_id
required: true
schema:
type: integer
format: int64
responses:
'200':
description: Title successfully deleted
# '400':
# description: Invalid request body (missing fields, invalid types, etc.)
'401':
description: Unauthorized — missing or invalid auth token
'403':
description: Forbidden — user not allowed to delete title
'404':
description: User or Title not found
'500':
description: Internal server error

View file

@ -28,12 +28,16 @@ get:
patch:
summary: Partially update a user account
security:
- JwtAuthCookies: []
description: |
Update selected user profile fields (excluding password).
Password updates must be done via the dedicated auth-service (`/auth/`).
Fields not provided in the request body remain unchanged.
operationId: updateUser
parameters:
- $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc
- $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF
- name: user_id
in: path
required: true

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"
UserTitle:
$ref: "./UserTitle.yaml"
# JwtAuth:
# $ref: "./JWTAuth.yaml"

View file

@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface {
}
type PostAuthSignIn200JSONResponse struct {
Error *string `json:"error"`
UserId *string `json:"user_id"`
UserName *string `json:"user_name"`
UserId int64 `json:"user_id"`
UserName string `json:"user_name"`
}
func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error {
@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface {
}
type PostAuthSignUp200JSONResponse struct {
Error *string `json:"error"`
Success *bool `json:"success,omitempty"`
UserId *string `json:"user_id"`
UserId int64 `json:"user_id"`
}
func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error {

View file

@ -30,16 +30,13 @@ paths:
content:
application/json:
schema:
required:
- user_id
type: object
properties:
success:
type: boolean
error:
type: string
nullable: true
user_id:
type: string
nullable: true
type: integer
format: int64
/auth/sign-in:
post:
@ -65,17 +62,16 @@ paths:
content:
application/json:
schema:
required:
- user_id
- user_name
type: object
properties:
error:
type: string
nullable: true
user_id:
type: string
nullable: true
type: integer
format: int64
user_name:
type: string
nullable: true
"401":
description: Access denied due to invalid credentials
content:

View file

@ -11,20 +11,34 @@ services:
- "${POSTGRES_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql
networks:
- nyanimedb-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# pgadmin:
# image: dpage/pgadmin4:${PGADMIN_VERSION}
# container_name: pgadmin
# restart: always
# environment:
# PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
# PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
# ports:
# - "${PGADMIN_PORT}:80"
# depends_on:
# - postgres
# volumes:
# - pgadmin_data:/var/lib/pgadmin
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
networks:
- nyanimedb-network
healthcheck:
test: ["CMD", "rabbitmqctl", "status"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
nyanimedb-backend:
image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest
@ -37,6 +51,9 @@ services:
- "8080:8080"
depends_on:
- postgres
- rabbitmq
networks:
- nyanimedb-network
nyanimedb-auth:
image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest
@ -49,6 +66,8 @@ services:
- "8082:8082"
depends_on:
- postgres
networks:
- nyanimedb-network
nyanimedb-frontend:
image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
@ -58,7 +77,12 @@ services:
- "8081:80"
depends_on:
- nyanimedb-backend
networks:
- nyanimedb-network
volumes:
postgres_data:
pgadmin_data:
rabbitmq_data:
networks:
nyanimedb-network:

3
go.mod
View file

@ -3,6 +3,7 @@ module nyanimedb
go 1.25.0
require (
github.com/alexedwards/argon2id v1.0.0
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
@ -36,7 +37,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/streadway/amqp v1.1.0 // indirect
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect

42
go.sum
View file

@ -1,4 +1,6 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
@ -70,6 +72,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
@ -89,26 +93,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -3,22 +3,21 @@ package handlers
import (
"context"
"fmt"
"log"
"net/http"
auth "nyanimedb/auth"
sqlc "nyanimedb/sql"
"strconv"
"time"
"github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
)
var accessSecret = []byte("my_access_secret_key")
var refreshSecret = []byte("my_refresh_secret_key")
var UserDb = make(map[string]string) // TEMP: stores passwords
type Server struct {
db *sqlc.Queries
}
@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) {
return int32(i), err
}
func HashPassword(password string) (string, error) {
params := &argon2id.Params{
Memory: 64 * 1024,
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
return argon2id.CreateHash(password, params)
}
func CheckPassword(password, hash string) (bool, error) {
return argon2id.ComparePasswordAndHash(password, hash)
}
func generateTokens(userID string) (accessToken string, refreshToken string, err error) {
accessClaims := jwt.MapClaims{
"user_id": userID,
@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err
}
func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) {
err := ""
success := true
UserDb[req.Body.Nickname] = req.Body.Pass
passhash, err := HashPassword(req.Body.Pass)
if err != nil {
log.Errorf("failed to hash password: %v", err)
// TODO: return 500
}
user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{
Passhash: passhash,
Nickname: req.Body.Nickname,
})
if err != nil {
log.Errorf("failed to create user %s: %v", req.Body.Nickname, err)
// TODO: check err and retyrn 400/500
}
return auth.PostAuthSignUp200JSONResponse{
Error: &err,
Success: &success,
UserId: &req.Body.Nickname,
UserId: user_id,
}, nil
}
func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) {
// ctx.SetCookie("122")
ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context)
if !ok {
log.Print("failed to get gin context")
@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque
return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context")
}
err := ""
user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname)
if err != nil {
log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err)
// TODO: return 400/500
}
pass, ok := UserDb[req.Body.Nickname]
if !ok || pass != req.Body.Pass {
e := "invalid credentials"
ok, err = CheckPassword(req.Body.Pass, user.Passhash)
if err != nil {
log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err)
// TODO: return 500
}
if !ok {
err_msg := "invalid credentials"
return auth.PostAuthSignIn401JSONResponse{
Error: &e,
Error: &err_msg,
}, nil
}
accessToken, refreshToken, _ := generateTokens(req.Body.Nickname)
accessToken, refreshToken, err := generateTokens(req.Body.Nickname)
if err != nil {
log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err)
// TODO: return 500
}
// TODO: check cookie settings carefully
ginCtx.SetSameSite(http.SameSiteStrictMode)
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true)
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true)
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true)
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true)
// Return access token; refresh token can be returned in response or HttpOnly cookie
result := auth.PostAuthSignIn200JSONResponse{
Error: &err,
UserId: &req.Body.Nickname,
UserName: &req.Body.Nickname,
UserId: user.ID,
UserName: user.Nickname,
}
return result, nil
}

View file

@ -1,6 +1,9 @@
package main
import (
"context"
"fmt"
"os"
"time"
auth "nyanimedb/auth"
@ -9,14 +12,22 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
var AppConfig Config
func main() {
// TODO: env args
r := gin.Default()
var queries *sqlc.Queries = nil
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
var queries *sqlc.Queries = sqlc.New(pool)
server := handlers.NewServer(queries)

11
modules/auth/queries.sql Normal file
View file

@ -0,0 +1,11 @@
-- name: GetUserByNickname :one
SELECT *
FROM users
WHERE nickname = sqlc.arg('nickname');
-- name: CreateNewUser :one
INSERT
INTO users (passhash, nickname)
VALUES (sqlc.arg(passhash), sqlc.arg(nickname))
RETURNING id;

View file

@ -4,16 +4,31 @@ import (
"encoding/json"
"fmt"
oapi "nyanimedb/api"
"nyanimedb/modules/backend/rmq"
sqlc "nyanimedb/sql"
"strconv"
)
type Server struct {
db *sqlc.Queries
type Handler struct {
publisher *rmq.Publisher
}
func NewServer(db *sqlc.Queries) Server {
return Server{db: db}
func New(publisher *rmq.Publisher) *Handler {
return &Handler{publisher: publisher}
}
type Server struct {
db *sqlc.Queries
publisher *rmq.Publisher
RPCclient *rmq.RPCClient
}
func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server {
return &Server{
db: db,
publisher: publisher,
RPCclient: rpcclient,
}
}
func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) {

View file

@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
oapi "nyanimedb/api"
"nyanimedb/modules/backend/rmq"
sqlc "nyanimedb/sql"
"strconv"
"time"
"github.com/jackc/pgx/v5"
log "github.com/sirupsen/logrus"
@ -154,21 +156,62 @@ func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject
}
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
opai_titles := make([]oapi.Title, 0)
mqreq := rmq.RabbitRequest{
Timestamp: time.Now(),
}
word := Word2Sqlc(request.Params.Word)
if word != nil {
mqreq.Name = *word
}
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err
}
if season != nil {
mqreq.Season = *request.Params.ReleaseSeason
}
title_statuses, err := TitleStatus2Sqlc(request.Params.Status)
if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err
}
if title_statuses != nil {
mqreq.Statuses = *request.Params.Status
}
if request.Params.ExtSearch != nil && *request.Params.ExtSearch {
// Структура для ответа (должна совпадать с тем, что шлёт микросервис)
var reply struct {
Status string `json:"status"`
Result string `json:"result"`
Preview string `json:"preview_url"`
}
// Делаем RPC-вызов — и ЖДЁМ ответа
err := s.RPCclient.Call(
ctx,
"svc.media.process.requests", // ← очередь микросервиса
mqreq,
&reply,
)
if err != nil {
log.Errorf("RabitMQ: %v", err)
// return oapi.GetTitles500Response{}, err
}
// // Возвращаем результат
// return oapi.ProcessMedia200JSONResponse{
// Status: reply.Status,
// Result: reply.Result,
// Preview: reply.Preview,
// }, nil
}
params := sqlc.SearchTitlesParams{
Word: word,

View file

@ -204,7 +204,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o
return oapi_usertitle, nil
}
func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) {
oapi_usertitles := make([]oapi.UserTitle, 0)
@ -213,7 +213,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, err
return oapi.GetUserTitles400Response{}, err
}
// var statuses_sort []string
@ -227,19 +227,19 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, err
return oapi.GetUserTitles400Response{}, err
}
title_statuses, err := TitleStatus2Sqlc(request.Params.Status)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, err
return oapi.GetUserTitles400Response{}, err
}
userID, err := parseInt64(request.UserId)
if err != nil {
log.Errorf("get user titles: %v", err)
return oapi.GetUsersUserIdTitles404Response{}, err
return oapi.GetUserTitles404Response{}, err
}
params := sqlc.SearchUserTitlesParams{
UserID: userID,
@ -265,7 +265,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), &params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles400Response{}, nil
return oapi.GetUserTitles400Response{}, nil
}
}
}
@ -273,10 +273,10 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
titles, err := s.db.SearchUserTitles(ctx, params)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles500Response{}, nil
return oapi.GetUserTitles500Response{}, nil
}
if len(titles) == 0 {
return oapi.GetUsersUserIdTitles204Response{}, nil
return oapi.GetUserTitles204Response{}, nil
}
var new_cursor oapi.CursorObj
@ -286,7 +286,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
t, err := s.mapUsertitle(ctx, title)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersUserIdTitles500Response{}, nil
return oapi.GetUserTitles500Response{}, nil
}
oapi_usertitles = append(oapi_usertitles, t)
@ -303,7 +303,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU
}
}
return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
}
func EmailToStringPtr(e *types.Email) *string {
@ -402,7 +402,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque
func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) {
params := sqlc.DeleteUserTitleParams{
UserID: request.UserId,
TitleID: request.Params.TitleId,
TitleID: request.TitleId,
}
_, err := s.db.DeleteUserTitle(ctx, params)
if err != nil {
@ -427,7 +427,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl
Status: status,
Rate: request.Body.Rate,
UserID: request.UserId,
TitleID: request.Body.TitleId,
TitleID: request.TitleId,
}
user_title, err := s.db.UpdateUserTitle(ctx, params)
@ -455,3 +455,33 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl
return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil
}
func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) {
user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{
TitleID: request.TitleId,
UserID: request.UserId,
})
if err != nil {
if err == pgx.ErrNoRows {
return oapi.GetUserTitle404Response{}, nil
} else {
log.Errorf("%v", err)
return oapi.GetUserTitle500Response{}, nil
}
}
oapi_status, err := sql2usertitlestatus(user_title.Status)
if err != nil {
log.Errorf("%v", err)
return oapi.GetUserTitle500Response{}, nil
}
oapi_usertitle := oapi.UserTitleMini{
Ctime: &user_title.Ctime,
Rate: user_title.Rate,
ReviewId: user_title.ReviewID,
Status: oapi_status,
TitleId: user_title.TitleID,
UserId: user_title.UserID,
}
return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil
}

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"net/http"
sqlc "nyanimedb/sql"
"os"
"reflect"
@ -10,11 +11,15 @@ import (
oapi "nyanimedb/api"
handlers "nyanimedb/modules/backend/handlers"
middleware "nyanimedb/modules/backend/middlewares"
"nyanimedb/modules/backend/rmq"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pelletier/go-toml/v2"
"github.com/rabbitmq/amqp091-go"
log "github.com/sirupsen/logrus"
)
var AppConfig Config
@ -41,14 +46,30 @@ func main() {
r := gin.Default()
r.Use(middleware.CSRFMiddleware())
// jwt middle will be here
queries := sqlc.New(pool)
server := handlers.NewServer(queries)
// r.LoadHTMLGlob("templates/*")
// === RabbitMQ setup ===
rmqURL := os.Getenv("RABBITMQ_URL")
if rmqURL == "" {
rmqURL = "amqp://guest:guest@rabbitmq:5672/"
}
rmqConn, err := amqp091.Dial(rmqURL)
if err != nil {
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
}
defer rmqConn.Close()
publisher := rmq.NewPublisher(rmqConn)
rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second)
server := handlers.NewServer(queries, publisher, rpcClient)
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
@ -60,24 +81,12 @@ func main() {
// сюда можно добавить middlewares, если нужно
[]oapi.StrictMiddlewareFunc{},
))
// r.GET("/", func(c *gin.Context) {
// c.HTML(http.StatusOK, "index.html", gin.H{
// "title": "Welcome Page",
// "message": "Hello, Gin with HTML templates!",
// })
// })
// r.GET("/api", func(c *gin.Context) {
// items := []Item{
// {ID: 1, Title: "First Item", Description: "This is the description of the first item."},
// {ID: 2, Title: "Second Item", Description: "This is the description of the second item."},
// {ID: 3, Title: "Third Item", Description: "This is the description of the third item."},
// }
// c.JSON(http.StatusOK, items)
// })
r.Run(":8080")
// Запуск
log.Infof("Server starting on :8080")
if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}
func InitConfig() error {

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

@ -394,4 +394,10 @@ RETURNING *;
DELETE FROM usertitles
WHERE user_id = sqlc.arg('user_id')
AND title_id = sqlc.arg('title_id')
RETURNING *;
RETURNING *;
-- name: GetUserTitleByID :one
SELECT
ut.*
FROM usertitles as ut
WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint;

View file

@ -1,103 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
oapi "nyanimedb/api"
"time"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/streadway/amqp"
)
type RabbitRequest struct {
Name string `json:"name"`
Status oapi.TitleStatus `json:"titlestatus,omitempty"`
Rating float64 `json:"titleraring,omitempty"`
Year int32 `json:"year,omitempty"`
Season oapi.ReleaseSeason `json:"season,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// PublishAndAwaitReply отправляет запрос и ждёт ответа от workerа.
// Возвращает раскодированный ответ или ошибку.
func PublishAndAwaitReply(
ctx context.Context,
ch *amqp.Channel,
requestQueue string, // например: "svc.media.process.requests"
request RabbitRequest, // ваша структура запроса
replyCh chan<- any, // куда положить ответ (вы читаете извне)
) error {
// 1. Создаём временную очередь для ответов
replyQueue, err := ch.QueueDeclare(
"", // auto-generated name
false, // not durable
true, // exclusive
true, // auto-delete
false, // no-wait
nil,
)
if err != nil {
return fmt.Errorf("failed to declare reply queue: %w", err)
}
// 2. Готовим корреляционный ID
corrID := uuid.New().String() // ← используйте github.com/google/uuid
logrus.Infof("New CorrID: %s", corrID)
// 3. Сериализуем запрос
body, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// 4. Публикуем запрос
err = ch.Publish(
"", // default exchange (или свой, если используете)
requestQueue,
false,
false,
amqp.Publishing{
ContentType: "application/json",
CorrelationId: corrID,
ReplyTo: replyQueue.Name,
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: body,
},
)
if err != nil {
return fmt.Errorf("failed to publish request, corrID: %s : %w", corrID, err)
}
// 5. Подписываемся на ответы
msgs, err := ch.Consume(
replyQueue.Name,
"", // consumer tag
true, // auto-ack
true, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
if err != nil {
return fmt.Errorf("failed to consume from reply queue: %w", err)
}
// 6. Ожидаем ответ с таймаутом
select {
case msg := <-msgs:
if msg.CorrelationId != corrID {
return fmt.Errorf("correlation ID mismatch: got %s, expected %s", msg.CorrelationId, corrID)
}
// Десериализуем — тут можно передать target-структуру или использовать interface{}
// В данном случае просто возвращаем байты или пусть вызывающая сторона парсит
replyCh <- msg.Body // или json.Unmarshal → и отправить структуру в канал
return nil
case <-ctx.Done():
return ctx.Err()
}
}

View file

@ -0,0 +1,261 @@
package rmq
import (
"context"
"encoding/json"
"fmt"
oapi "nyanimedb/api"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type RabbitRequest struct {
Name string `json:"name"`
Statuses []oapi.TitleStatus `json:"statuses,omitempty"`
Rating float64 `json:"rating,omitempty"`
Year int32 `json:"year,omitempty"`
Season oapi.ReleaseSeason `json:"season,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// Publisher — потокобезопасный публикатор с пулом каналов.
type Publisher struct {
conn *amqp.Connection
pool *sync.Pool
}
// NewPublisher создаёт новый Publisher.
// conn должен быть уже установленным и healthy.
// Рекомендуется передавать durable connection с reconnect-логикой.
func NewPublisher(conn *amqp.Connection) *Publisher {
return &Publisher{
conn: conn,
pool: &sync.Pool{
New: func() any {
ch, err := conn.Channel()
if err != nil {
// Паника уместна: невозможность открыть канал — критическая ошибка инициализации
panic(fmt.Errorf("rmqpool: failed to create channel: %w", err))
}
return ch
},
},
}
}
// Publish публикует сообщение в указанную очередь.
// Очередь объявляется как durable (если не существует).
// Поддерживает context для отмены/таймаута.
func (p *Publisher) Publish(
ctx context.Context,
queueName string,
payload RabbitRequest,
opts ...PublishOption,
) error {
// Применяем опции
options := &publishOptions{
contentType: "application/json",
deliveryMode: amqp.Persistent,
timestamp: time.Now(),
}
for _, opt := range opts {
opt(options)
}
// Сериализуем payload
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("rmqpool: failed to marshal payload: %w", err)
}
// Берём канал из пула
ch := p.getChannel()
if ch == nil {
return fmt.Errorf("rmqpool: channel is nil (connection may be closed)")
}
defer p.returnChannel(ch)
// Объявляем очередь (idempotent)
q, err := ch.QueueDeclare(
queueName,
true, // durable
false, // auto-delete
false, // exclusive
false, // no-wait
nil, // args
)
if err != nil {
return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err)
}
// Подготавливаем сообщение
msg := amqp.Publishing{
DeliveryMode: options.deliveryMode,
ContentType: options.contentType,
Timestamp: options.timestamp,
Body: body,
}
// Публикуем с учётом контекста
done := make(chan error, 1)
go func() {
err := ch.Publish(
"", // exchange (default)
q.Name, // routing key
false, // mandatory
false, // immediate
msg,
)
done <- err
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (p *Publisher) getChannel() *amqp.Channel {
raw := p.pool.Get()
if raw == nil {
ch, _ := p.conn.Channel()
return ch
}
ch := raw.(*amqp.Channel)
if ch.IsClosed() { // ← теперь есть!
ch.Close() // освободить ресурсы
ch, _ = p.conn.Channel()
}
return ch
}
// returnChannel возвращает канал в пул, если он жив.
func (p *Publisher) returnChannel(ch *amqp.Channel) {
if ch != nil && !ch.IsClosed() {
p.pool.Put(ch)
}
}
// PublishOption позволяет кастомизировать публикацию.
type PublishOption func(*publishOptions)
type publishOptions struct {
contentType string
deliveryMode uint8
timestamp time.Time
}
// WithContentType устанавливает Content-Type (по умолчанию "application/json").
func WithContentType(ct string) PublishOption {
return func(o *publishOptions) { o.contentType = ct }
}
// WithTransient делает сообщение transient (не сохраняется на диск).
// По умолчанию — Persistent.
func WithTransient() PublishOption {
return func(o *publishOptions) { o.deliveryMode = amqp.Transient }
}
// WithTimestamp устанавливает кастомную метку времени.
func WithTimestamp(ts time.Time) PublishOption {
return func(o *publishOptions) { o.timestamp = ts }
}
type RPCClient struct {
conn *amqp.Connection
timeout time.Duration
}
func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient {
return &RPCClient{conn: conn, timeout: timeout}
}
// Call отправляет запрос в очередь и ждёт ответа.
// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}).
func (c *RPCClient) Call(
ctx context.Context,
requestQueue string,
request RabbitRequest,
replyPayload any,
) error {
// 1. Создаём временный канал (не из пула!)
ch, err := c.conn.Channel()
if err != nil {
return fmt.Errorf("channel: %w", err)
}
defer ch.Close()
// 2. Создаём временную очередь для ответов
q, err := ch.QueueDeclare(
"", // auto name
false, // not durable
true, // exclusive
true, // auto-delete
false,
nil,
)
if err != nil {
return fmt.Errorf("reply queue: %w", err)
}
// 3. Подписываемся на ответы
msgs, err := ch.Consume(
q.Name,
"",
true, // auto-ack
true, // exclusive
false,
false,
nil,
)
if err != nil {
return fmt.Errorf("consume: %w", err)
}
// 4. Готовим correlation ID
corrID := time.Now().UnixNano()
// 5. Сериализуем запрос
body, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
// 6. Публикуем запрос
err = ch.Publish(
"",
requestQueue,
false,
false,
amqp.Publishing{
ContentType: "application/json",
CorrelationId: fmt.Sprintf("%d", corrID),
ReplyTo: q.Name,
Timestamp: time.Now(),
Body: body,
},
)
if err != nil {
return fmt.Errorf("publish: %w", err)
}
// 7. Ждём ответ с таймаутом
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
for {
select {
case msg := <-msgs:
if msg.CorrelationId == fmt.Sprintf("%d", corrID) {
return json.Unmarshal(msg.Body, replyPayload)
}
// игнорируем другие сообщения (маловероятно, но возможно)
case <-ctx.Done():
return ctx.Err() // timeout or cancelled
}
}
}

View file

@ -1,13 +1,12 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import UsersIdPage from "./pages/UsersIdPage/UsersIdPage";
import UserPage from "./pages/UserPage/UserPage";
import TitlesPage from "./pages/TitlesPage/TitlesPage";
import TitlePage from "./pages/TitlePage/TitlePage";
import { LoginPage } from "./pages/LoginPage/LoginPage";
import { Header } from "./components/Header/Header";
const App: React.FC = () => {
// Получаем username из localStorage
const username = localStorage.getItem("username") || undefined;
const userId = localStorage.getItem("userId");
@ -15,17 +14,20 @@ const App: React.FC = () => {
<Router>
<Header username={username} />
<Routes>
{/* auth */}
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<LoginPage />} />
{/* /profile рендерит UsersIdPage с id из localStorage */}
{/*<Route path="/signup" element={<LoginPage />} />*/}
{/* users */}
{/*<Route path="/users" element={<UsersPage />} />*/}
<Route path="/users/:id" element={<UserPage />} />
<Route
path="/profile"
element={userId ? <UsersIdPage userId={userId} /> : <LoginPage />}
element={userId ? <UserPage userId={userId} /> : <LoginPage />}
/>
<Route path="/users/:id" element={<UsersIdPage />} />
{/* titles */}
<Route path="/titles" element={<TitlesPage />} />
<Route path="/titles/:id" element={<TitlePage />} />
</Routes>

View file

@ -20,7 +20,7 @@ export type OpenAPIConfig = {
};
export const OpenAPI: OpenAPIConfig = {
BASE: 'http://10.1.0.65:8081/api/v1',
BASE: '/api/v1',
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',

View file

@ -199,7 +199,7 @@ export class DefaultService {
* @returns any List of user titles
* @throws ApiError
*/
public static getUsersTitles(
public static getUserTitles(
userId: string,
cursor?: string,
sort?: TitleSort,
@ -278,27 +278,54 @@ export class DefaultService {
},
});
}
/**
* Get user title
* @param userId
* @param titleId
* @returns UserTitleMini User titles
* @throws ApiError
*/
public static getUserTitle(
userId: number,
titleId: number,
): CancelablePromise<UserTitleMini> {
return __request(OpenAPI, {
method: 'GET',
url: '/users/{user_id}/titles/{title_id}',
path: {
'user_id': userId,
'title_id': titleId,
},
errors: {
400: `Request params are not correct`,
404: `User or title not found`,
500: `Unknown server error`,
},
});
}
/**
* Update a usertitle
* User updating title list of watched
* @param userId ID of the user to assign the title to
* @param userId
* @param titleId
* @param requestBody
* @returns UserTitleMini Title successfully updated
* @throws ApiError
*/
public static updateUserTitle(
userId: number,
titleId: number,
requestBody: {
title_id: number;
status?: UserTitleStatus;
rate?: number;
},
): CancelablePromise<UserTitleMini> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/users/{user_id}/titles',
url: '/users/{user_id}/titles/{title_id}',
path: {
'user_id': userId,
'title_id': titleId,
},
body: requestBody,
mediaType: 'application/json',
@ -311,4 +338,31 @@ export class DefaultService {
},
});
}
/**
* Delete a usertitle
* User deleting title from list of watched
* @param userId
* @param titleId
* @returns any Title successfully deleted
* @throws ApiError
*/
public static deleteUserTitle(
userId: number,
titleId: number,
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/users/{user_id}/titles/{title_id}',
path: {
'user_id': userId,
'title_id': titleId,
},
errors: {
401: `Unauthorized — missing or invalid auth token`,
403: `Forbidden — user not allowed to delete title`,
404: `User or Title not found`,
500: `Internal server error`,
},
});
}
}

View file

@ -20,7 +20,7 @@ export type OpenAPIConfig = {
};
export const OpenAPI: OpenAPIConfig = {
BASE: 'http://10.1.0.65:8081/auth',
BASE: '/auth',
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',

View file

@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { DefaultService } from "../../api";
import type { UserTitleStatus } from "../../api";
import {
ClockIcon,
CheckCircleIcon,
PlayCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
// Статусы с иконками и подписью
const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [
{ status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" },
{ status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" },
{ status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" },
{ status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" },
];
export function TitleStatusControls({ titleId }: { titleId: number }) {
const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null);
const [loading, setLoading] = useState(false);
const userIdStr = localStorage.getItem("userId");
const userId = userIdStr ? Number(userIdStr) : null;
// --- Load initial status ---
useEffect(() => {
if (!userId) return;
DefaultService.getUserTitle(userId, titleId)
.then((res) => setCurrentStatus(res.status))
.catch(() => setCurrentStatus(null)); // 404 = user title does not exist
}, [titleId, userId]);
// --- Handle click ---
const handleStatusClick = async (status: UserTitleStatus) => {
if (!userId || loading) return;
setLoading(true);
try {
// 1) Если кликнули на текущий статус — DELETE
if (currentStatus === status) {
await DefaultService.deleteUserTitle(userId, titleId);
setCurrentStatus(null);
return;
}
// 2) Если другой статус — POST или PATCH
if (!currentStatus) {
// ещё нет записи — POST
const added = await DefaultService.addUserTitle(userId, {
title_id: titleId,
status,
});
setCurrentStatus(added.status);
} else {
// уже есть запись — PATCH
const updated = await DefaultService.updateUserTitle(userId, titleId, { status });
setCurrentStatus(updated.status);
}
} finally {
setLoading(false);
}
};
return (
<div className="flex gap-2 flex-wrap justify-center mt-2">
{STATUS_BUTTONS.map(btn => (
<button
key={btn.status}
onClick={() => handleStatusClick(btn.status)}
disabled={loading}
className={`
px-3 py-1 rounded-md border flex items-center gap-1 transition
${currentStatus === btn.status
? "bg-blue-600 text-white border-blue-700"
: "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"}
`}
title={btn.label}
>
{btn.icon}
<span>{btn.label}</span>
</button>
))}
</div>
);
}

View file

@ -1,20 +1,8 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, Link } from "react-router-dom";
import { DefaultService } from "../../api/services/DefaultService";
import type { Title, UserTitleStatus } from "../../api";
import {
ClockIcon,
CheckCircleIcon,
PlayCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [
{ status: "planned", icon: <ClockIcon className="w-6 h-6" />, label: "Planned" },
{ status: "finished", icon: <CheckCircleIcon className="w-6 h-6" />, label: "Finished" },
{ status: "in-progress", icon: <PlayCircleIcon className="w-6 h-6" />, label: "In Progress" },
{ status: "dropped", icon: <XCircleIcon className="w-6 h-6" />, label: "Dropped" },
];
import type { Title } from "../../api";
import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls";
export default function TitlePage() {
const params = useParams();
@ -24,9 +12,9 @@ export default function TitlePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userStatus, setUserStatus] = useState<UserTitleStatus | null>(null);
const [updatingStatus, setUpdatingStatus] = useState(false);
// ---------------------------
// LOAD TITLE INFO
// ---------------------------
useEffect(() => {
const fetchTitle = async () => {
setLoading(true);
@ -44,30 +32,6 @@ export default function TitlePage() {
fetchTitle();
}, [titleId]);
const handleStatusClick = async (status: UserTitleStatus) => {
if (updatingStatus || userStatus === status) return;
const userId = Number(localStorage.getItem("userId"));
if (!userId) {
alert("You must be logged in to set status.");
return;
}
setUpdatingStatus(true);
try {
await DefaultService.addUserTitle(userId, {
title_id: titleId,
status,
});
setUserStatus(status);
} catch (err: any) {
console.error(err);
alert(err?.message || "Failed to set status");
} finally {
setUpdatingStatus(false);
}
};
const getTagsString = () =>
title?.tags?.map(tag => tag.en).filter(Boolean).join(", ");
@ -78,7 +42,7 @@ export default function TitlePage() {
return (
<div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center">
<div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6">
{/* Постер */}
{/* Poster + status buttons */}
<div className="flex flex-col items-center">
<img
src={title.poster?.image_path || "/default-poster.png"}
@ -86,48 +50,52 @@ export default function TitlePage() {
className="w-48 h-72 object-cover rounded-lg mb-4"
/>
{/* Статус кнопки с иконками */}
<div className="flex gap-2 mt-2 flex-wrap justify-center">
{STATUS_BUTTONS.map(btn => (
<button
key={btn.status}
onClick={() => handleStatusClick(btn.status)}
disabled={updatingStatus}
className={`p-2 rounded-lg transition flex items-center justify-center ${
userStatus === btn.status
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
title={btn.label}
>
{btn.icon}
</button>
))}
</div>
{/* Status buttons */}
<TitleStatusControls titleId={titleId} />
</div>
{/* Информация о тайтле */}
{/* Title info */}
<div className="flex-1 flex flex-col">
<h1 className="text-3xl font-bold mb-2">
{title.title_names?.en?.[0] || "Untitled"}
</h1>
{title.studio && <p className="text-gray-700 mb-1">Studio: {title.studio.name}</p>}
{title.studio && (
<p className="text-gray-700 mb-1">
Studio:{" "}
{title.studio.id ? (
<Link
to={`/studios/${title.studio.id}`}
className="text-blue-600 hover:underline"
>
{title.studio.name}
</Link>
) : (
title.studio.name
)}
</p>
)}
{title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>}
{title.rating !== undefined && (
<p className="text-gray-700 mb-1">
Rating: {title.rating} ({title.rating_count} votes)
</p>
)}
{title.release_year && (
<p className="text-gray-700 mb-1">
Released: {title.release_year} {title.release_season || ""}
</p>
)}
{title.episodes_aired !== undefined && (
<p className="text-gray-700 mb-1">
Episodes: {title.episodes_aired}/{title.episodes_all}
</p>
)}
{title.tags && title.tags.length > 0 && (
<p className="text-gray-700 mb-1">
Tags: {getTagsString()}

View file

@ -1 +0,0 @@
@import "tailwindcss";

View file

@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare";
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal";
import type { CursorObj, Title, TitleSort } from "../../api";
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
import { Link } from "react-router-dom";
const PAGE_SIZE = 10;
@ -135,11 +136,11 @@ const handleLoadMore = async () => {
hasMore={!!cursor || nextPage.length > 1}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
renderItem={(title, layout) =>
layout === "square"
? <TitleCardSquare title={title} />
: <TitleCardHorizontal title={title} />
}
renderItem={(title, layout) => (
<Link to={`/titles/${title.id}`} key={title.id} className="block">
{layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />}
</Link>
)}
/>
{!cursor && nextPage.length == 0 && (

View file

@ -1,103 +0,0 @@
body,
html {
width: 100%;
margin: 0;
background-color: #777;
color: #fff;
}
html,
body,
#root {
height: 100%;
}
.header {
width: 100vw;
padding: 30px 40px;
background: #f7f7f7;
display: flex;
align-items: center;
gap: 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-bottom: 1px solid #e5e5e5;
color: #000000;
}
.avatarWrapper {
width: 120px;
height: 120px;
min-width: 120px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #ddd;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
border-radius: 50%;
background: #ccc;
font-size: 42px;
font-weight: bold;
color: #555;
display: flex;
align-items: center;
justify-content: center;
}
.userInfo {
display: flex;
flex-direction: column;
}
.name {
font-size: 32px;
font-weight: 700;
margin: 0;
}
.nickname {
font-size: 18px;
color: #666;
margin-top: 6px;
}
.container {
max-width: 100vw;
width: 100%;
position: absolute;
top: 0%;
/* margin: 25px auto; */
/* padding: 0 20px; */
}
.content {
margin-top: 20px;
}
.desc {
font-size: 18px;
margin-bottom: 10px;
}
.created {
font-size: 16px;
color: #888;
}
.loader,
.error {
text-align: center;
margin-top: 40px;
font-size: 18px;
}

View file

@ -1,67 +1,184 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; // <-- import
// pages/UserPage/UserPage.tsx
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { DefaultService } from "../../api/services/DefaultService";
import type { User } from "../../api/models/User";
import styles from "./UserPage.module.css";
import { SearchBar } from "../../components/SearchBar/SearchBar";
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
import { ListView } from "../../components/ListView/ListView";
import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare";
import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal";
import type { User, UserTitle, CursorObj, TitleSort } from "../../api";
import { Link } from "react-router-dom";
const UserPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); // <-- get user id from URL
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const PAGE_SIZE = 10;
useEffect(() => {
if (!id) return;
const getUserInfo = async () => {
try {
const userInfo = await DefaultService.getUsersId(id, "all"); // <-- use dynamic id
setUser(userInfo);
} catch (err) {
console.error(err);
setError("Failed to fetch user info.");
} finally {
setLoading(false);
}
};
getUserInfo();
}, [id]);
if (loading) return <div className={styles.loader}>Loading...</div>;
if (error) return <div className={styles.error}>{error}</div>;
if (!user) return <div className={styles.error}>User not found.</div>;
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.avatarWrapper}>
{user.image?.image_path ? (
<img
src={`/images/${user.image.image_path}.png`}
alt="User Avatar"
className={styles.avatarImg}
/>
) : (
<div className={styles.avatarPlaceholder}>
{user.disp_name?.[0] || "U"}
</div>
)}
</div>
<div className={styles.userInfo}>
<h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
<p className={styles.nickname}>@{user.nickname}</p>
{/* <p className={styles.created}>
Joined: {new Date(user.creation_date).toLocaleDateString()}
</p> */}
</div>
<div className={styles.content}>
{user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
</div>
</div>
</div>
);
type UserPageProps = {
userId?: string;
};
export default UserPage;
export default function UserPage({ userId }: UserPageProps) {
const params = useParams();
const id = userId || params?.id;
const [user, setUser] = useState<User | null>(null);
const [loadingUser, setLoadingUser] = useState(true);
const [errorUser, setErrorUser] = useState<string | null>(null);
// Для списка тайтлов
const [titles, setTitles] = useState<UserTitle[]>([]);
const [nextPage, setNextPage] = useState<UserTitle[]>([]);
const [cursor, setCursor] = useState<CursorObj | null>(null);
const [loadingTitles, setLoadingTitles] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<TitleSort>("id");
const [sortForward, setSortForward] = useState(true);
const [layout, setLayout] = useState<"square" | "horizontal">("square");
// --- Получение данных пользователя ---
useEffect(() => {
const fetchUser = async () => {
if (!id) return;
setLoadingUser(true);
try {
const result = await DefaultService.getUsersId(id, "all");
setUser(result);
setErrorUser(null);
} catch (err: any) {
console.error(err);
setErrorUser(err?.message || "Failed to fetch user data");
} finally {
setLoadingUser(false);
}
};
fetchUser();
}, [id]);
// --- Получение списка тайтлов пользователя ---
const fetchPage = async (cursorObj: CursorObj | null) => {
if (!id) return { items: [], nextCursor: null };
const cursorStr = cursorObj
? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
: "";
try {
const result = await DefaultService.getUserTitles(
id,
cursorStr,
sort,
sortForward,
search.trim() || undefined,
undefined, // status фильтр, можно добавить
undefined, // watchStatus
undefined, // rating
undefined, // myRate
undefined, // releaseYear
undefined, // releaseSeason
PAGE_SIZE,
"all"
);
if (!result?.data?.length) return { items: [], nextCursor: null };
return { items: result.data, nextCursor: result.cursor ?? null };
} catch (err: any) {
if (err.status === 204) return { items: [], nextCursor: null };
throw err;
}
};
// Инициализация: загружаем сразу две страницы
useEffect(() => {
const initLoad = async () => {
setLoadingTitles(true);
setTitles([]);
setNextPage([]);
setCursor(null);
const firstPage = await fetchPage(null);
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
setTitles(firstPage.items);
setNextPage(secondPage.items);
setCursor(secondPage.nextCursor);
setLoadingTitles(false);
};
initLoad();
}, [id, search, sort, sortForward]);
const handleLoadMore = async () => {
if (nextPage.length === 0) {
setLoadingMore(false);
return;
}
setLoadingMore(true);
setTitles(prev => [...prev, ...nextPage]);
setNextPage([]);
if (cursor) {
try {
const next = await fetchPage(cursor);
if (next.items.length > 0) setNextPage(next.items);
setCursor(next.nextCursor);
} catch (err) {
console.error(err);
}
}
setLoadingMore(false);
};
return (
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
{/* --- Карточка пользователя --- */}
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>}
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>}
{user && (
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8">
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" />
<h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2>
{user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>}
{user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>}
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>}
</div>
)}
{/* --- Панель поиска, сортировки и лейаута --- */}
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
<LayoutSwitch layout={layout} setLayout={setLayout} />
<TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} />
</div>
{/* --- Список тайтлов --- */}
{loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>}
{!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>}
{titles.length > 0 && (
<>
<ListView<UserTitle>
items={titles}
layout={layout}
hasMore={!!cursor || nextPage.length > 1}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
renderItem={(title, layout) => (
<Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block">
{layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />}
</Link>
)}
/>
{!cursor && nextPage.length === 0 && (
<div className="mt-6 font-medium text-black">
Результатов больше нет, было найдено {titles.length} тайтлов.
</div>
)}
</>
)}
</div>
);
}

View file

@ -1,183 +0,0 @@
// pages/UserPage/UserPage.tsx
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { DefaultService } from "../../api/services/DefaultService";
import { SearchBar } from "../../components/SearchBar/SearchBar";
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
import { ListView } from "../../components/ListView/ListView";
import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare";
import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal";
import type { User, UserTitle, CursorObj, TitleSort } from "../../api";
const PAGE_SIZE = 10;
type UsersIdPageProps = {
userId?: string;
};
export default function UsersIdPage({ userId }: UsersIdPageProps) {
const params = useParams();
const id = userId || params?.id;
const [user, setUser] = useState<User | null>(null);
const [loadingUser, setLoadingUser] = useState(true);
const [errorUser, setErrorUser] = useState<string | null>(null);
// Для списка тайтлов
const [titles, setTitles] = useState<UserTitle[]>([]);
const [nextPage, setNextPage] = useState<UserTitle[]>([]);
const [cursor, setCursor] = useState<CursorObj | null>(null);
const [loadingTitles, setLoadingTitles] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<TitleSort>("id");
const [sortForward, setSortForward] = useState(true);
const [layout, setLayout] = useState<"square" | "horizontal">("square");
// --- Получение данных пользователя ---
useEffect(() => {
const fetchUser = async () => {
if (!id) return;
setLoadingUser(true);
try {
const result = await DefaultService.getUsersId(id, "all");
setUser(result);
setErrorUser(null);
} catch (err: any) {
console.error(err);
setErrorUser(err?.message || "Failed to fetch user data");
} finally {
setLoadingUser(false);
}
};
fetchUser();
}, [id]);
// --- Получение списка тайтлов пользователя ---
const fetchPage = async (cursorObj: CursorObj | null) => {
if (!id) return { items: [], nextCursor: null };
const cursorStr = cursorObj
? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
: "";
try {
const result = await DefaultService.getUsersTitles(
id,
cursorStr,
sort,
sortForward,
search.trim() || undefined,
undefined, // status фильтр, можно добавить
undefined, // watchStatus
undefined, // rating
undefined, // myRate
undefined, // releaseYear
undefined, // releaseSeason
PAGE_SIZE,
"all"
);
if (!result?.data?.length) return { items: [], nextCursor: null };
return { items: result.data, nextCursor: result.cursor ?? null };
} catch (err: any) {
if (err.status === 204) return { items: [], nextCursor: null };
throw err;
}
};
// Инициализация: загружаем сразу две страницы
useEffect(() => {
const initLoad = async () => {
setLoadingTitles(true);
setTitles([]);
setNextPage([]);
setCursor(null);
const firstPage = await fetchPage(null);
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
setTitles(firstPage.items);
setNextPage(secondPage.items);
setCursor(secondPage.nextCursor);
setLoadingTitles(false);
};
initLoad();
}, [id, search, sort, sortForward]);
const handleLoadMore = async () => {
if (nextPage.length === 0) {
setLoadingMore(false);
return;
}
setLoadingMore(true);
setTitles(prev => [...prev, ...nextPage]);
setNextPage([]);
if (cursor) {
try {
const next = await fetchPage(cursor);
if (next.items.length > 0) setNextPage(next.items);
setCursor(next.nextCursor);
} catch (err) {
console.error(err);
}
}
setLoadingMore(false);
};
// const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png");
return (
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
{/* --- Карточка пользователя --- */}
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>}
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>}
{user && (
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8">
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" />
<h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2>
{user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>}
{user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>}
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>}
</div>
)}
{/* --- Панель поиска, сортировки и лейаута --- */}
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
<LayoutSwitch layout={layout} setLayout={setLayout} />
<TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} />
</div>
{/* --- Список тайтлов --- */}
{loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>}
{!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>}
{titles.length > 0 && (
<>
<ListView<UserTitle>
items={titles}
layout={layout}
hasMore={!!cursor || nextPage.length > 1}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
renderItem={(title, layout) =>
layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />
}
/>
{!cursor && nextPage.length === 0 && (
<div className="mt-6 font-medium text-black">
Результатов больше нет, было найдено {titles.length} тайтлов.
</div>
)}
</>
)}
</div>
);
}

View file

@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image
return i, err
}
const createNewUser = `-- name: CreateNewUser :one
INSERT
INTO users (passhash, nickname)
VALUES ($1, $2)
RETURNING id
`
type CreateNewUserParams struct {
Passhash string `json:"passhash"`
Nickname string `json:"nickname"`
}
func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) {
row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname)
var id int64
err := row.Scan(&id)
return id, err
}
const deleteUserTitle = `-- name: DeleteUserTitle :one
DELETE FROM usertitles
WHERE user_id = $1
@ -262,6 +281,55 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er
return i, err
}
const getUserByNickname = `-- name: GetUserByNickname :one
SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login
FROM users
WHERE nickname = $1
`
func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) {
row := q.db.QueryRow(ctx, getUserByNickname, nickname)
var i User
err := row.Scan(
&i.ID,
&i.AvatarID,
&i.Passhash,
&i.Mail,
&i.Nickname,
&i.DispName,
&i.UserDesc,
&i.CreationDate,
&i.LastLogin,
)
return i, err
}
const getUserTitleByID = `-- name: GetUserTitleByID :one
SELECT
ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime
FROM usertitles as ut
WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint
`
type GetUserTitleByIDParams struct {
TitleID int64 `json:"title_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) {
row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID)
var i Usertitle
err := row.Scan(
&i.UserID,
&i.TitleID,
&i.Status,
&i.Rate,
&i.ReviewID,
&i.Ctime,
)
return i, err
}
const insertStudio = `-- name: InsertStudio :one
INSERT INTO studios (studio_name, illust_id, studio_desc)
VALUES (

View file

@ -3,6 +3,7 @@ sql:
- engine: "postgresql"
queries:
- "../modules/backend/queries.sql"
- "../modules/auth/queries.sql"
schema: "migrations"
gen:
go: