From 79e8ece9482b5f19f00d3aee9227668f81053e57 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 01/14] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From 6cbf0afb33e73939ec54b5785964d90168ffca33 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 02/14] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index a46da86..371337f 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -268,6 +287,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index de67bcf..a4d8875 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 3528ea7d344b471fec6923d9fa2ba3ec8b7c7fa9 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 06:42:07 +0300 Subject: [PATCH 03/14] cicd: removed go mod tidy for go builds --- .forgejo/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 0338440..87f3655 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -25,7 +25,6 @@ jobs: - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -38,7 +37,6 @@ jobs: - name: Build auth run: | cd modules/auth - go mod tidy go build -o auth . tar -czvf nyanimedb-auth.tar.gz auth From 40e0b14f2a909f6dfe779c779446c22cf7558176 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 09:42:05 +0300 Subject: [PATCH 04/14] feat: use postgres to fetch and store user info --- auth/auth.gen.go | 9 ++-- auth/openapi-auth.yaml | 22 ++++----- go.mod | 1 + go.sum | 40 ++++++++++++++++ modules/auth/handlers/handlers.go | 78 ++++++++++++++++++++++--------- modules/auth/main.go | 13 +++++- modules/auth/queries.sql | 11 +++++ sql/queries.sql.go | 42 +++++++++++++++++ sql/sqlc.yaml | 1 + 9 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 modules/auth/queries.sql diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b24deb5..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - UserId *string `json:"user_id"` - UserName *string `json:"user_name"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 0fe308c..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -65,17 +62,16 @@ paths: content: application/json: schema: + required: + - user_id + - user_name type: object properties: - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/go.mod b/go.mod index bf73121..7b7cc71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 diff --git a/go.sum b/go.sum index 8f46514..cd197e6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -87,26 +89,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 7f675aa..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,27 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - UserId: &req.Body.Nickname, - UserName: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..3318a14 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -55,6 +55,25 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -262,6 +281,29 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 8f8626a..904abaf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: From 9338c6504051462f362f0ccf26085f2d108b7c05 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 09:44:41 +0300 Subject: [PATCH 05/14] chore: updated sqlc generated code --- sql/queries.sql.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 3318a14..c1186b5 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -29,6 +29,25 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + const deleteUserTitle = `-- name: DeleteUserTitle :one DELETE FROM usertitles WHERE user_id = $1 @@ -55,25 +74,6 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } -const createNewUser = `-- name: CreateNewUser :one -INSERT -INTO users (passhash, nickname) -VALUES ($1, $2) -RETURNING id -` - -type CreateNewUserParams struct { - Passhash string `json:"passhash"` - Nickname string `json:"nickname"` -} - -func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { - row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) - var id int64 - err := row.Scan(&id) - return id, err -} - const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 98178731b9f6a03a9cd1a31b8005be70fc14492e Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 09:51:49 +0300 Subject: [PATCH 06/14] refact: UsersIdPage -> UserPage --- modules/frontend/src/App.tsx | 16 +- .../src/pages/UserPage/UserPage.module.css | 103 -------- .../frontend/src/pages/UserPage/UserPage.tsx | 240 +++++++++++++----- .../src/pages/UsersIdPage/UsersIdPage.tsx | 183 ------------- 4 files changed, 187 insertions(+), 355 deletions(-) delete mode 100644 modules/frontend/src/pages/UserPage/UserPage.module.css delete mode 100644 modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index e2c909f..95b59e3 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,13 +1,12 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import UsersIdPage from "./pages/UsersIdPage/UsersIdPage"; +import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - // Получаем username из localStorage const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); @@ -15,17 +14,20 @@ const App: React.FC = () => {
+ {/* auth */} } /> } /> - - {/* /profile рендерит UsersIdPage с id из localStorage */} + {/*} />*/} + + {/* users */} + {/*} />*/} + } /> : } + element={userId ? : } /> - } /> - + {/* titles */} } /> } /> diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css deleted file mode 100644 index 7f350c8..0000000 --- a/modules/frontend/src/pages/UserPage/UserPage.module.css +++ /dev/null @@ -1,103 +0,0 @@ -body, -html { - width: 100%; - margin: 0; - background-color: #777; - color: #fff; -} - -html, -body, -#root { - height: 100%; -} - -.header { - width: 100vw; - padding: 30px 40px; - background: #f7f7f7; - display: flex; - align-items: center; - gap: 25px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - border-bottom: 1px solid #e5e5e5; - color: #000000; -} - -.avatarWrapper { - width: 120px; - height: 120px; - min-width: 120px; - border-radius: 50%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #ddd; -} - -.avatarImg { - width: 100%; - height: 100%; - object-fit: cover; -} - -.avatarPlaceholder { - width: 100%; - height: 100%; - border-radius: 50%; - background: #ccc; - font-size: 42px; - font-weight: bold; - color: #555; - display: flex; - align-items: center; - justify-content: center; -} - -.userInfo { - display: flex; - flex-direction: column; -} - -.name { - font-size: 32px; - font-weight: 700; - margin: 0; -} - -.nickname { - font-size: 18px; - color: #666; - margin-top: 6px; -} - -.container { - max-width: 100vw; - width: 100%; - position: absolute; - top: 0%; - /* margin: 25px auto; */ - /* padding: 0 20px; */ -} - -.content { - margin-top: 20px; -} - -.desc { - font-size: 18px; - margin-bottom: 10px; -} - -.created { - font-size: 16px; - color: #888; -} - -.loader, -.error { - text-align: center; - margin-top: 40px; - font-size: 18px; -} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index eafdf6b..5fbd6b8 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,67 +1,183 @@ -import React, { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; // <-- import +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { User } from "../../api/models/User"; -import styles from "./UserPage.module.css"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; -const UserPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); // <-- get user id from URL - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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
Loading...
; - if (error) return
{error}
; - if (!user) return
User not found.
; - - return ( -
-
-
- {user.image?.image_path ? ( - User Avatar - ) : ( -
- {user.disp_name?.[0] || "U"} -
- )} -
- -
-

{user.disp_name || user.nickname}

-

@{user.nickname}

- {/*

- Joined: {new Date(user.creation_date).toLocaleDateString()} -

*/} -
- -
- {user.user_desc &&

{user.user_desc}

} -
-
-
- ); +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(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState(null); + + // Для списка тайтлов + const [titles, setTitles] = useState([]); + const [nextPage, setNextPage] = useState([]); + const [cursor, setCursor] = useState(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("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 ( +
+ + {/* --- Карточка пользователя --- */} + {loadingUser &&
Loading user...
} + {errorUser &&
{errorUser}
} + {user && ( +
+ {user.nickname} +

{user.disp_name || user.nickname}

+ {user.mail &&

{user.mail}

} + {user.user_desc &&

{user.user_desc}

} + {user.creation_date &&

Registered: {new Date(user.creation_date).toLocaleDateString()}

} +
+ )} + + {/* --- Панель поиска, сортировки и лейаута --- */} +
+ + + +
+ + {/* --- Список тайтлов --- */} + {loadingTitles &&
Loading titles...
} + {!loadingTitles && titles.length === 0 &&
No titles found.
} + + {titles.length > 0 && ( + <> + + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => + layout === "square" ? : + } + /> + + {!cursor && nextPage.length === 0 && ( +
+ Результатов больше нет, было найдено {titles.length} тайтлов. +
+ )} + + )} +
+ ); +} diff --git a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx b/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx deleted file mode 100644 index 729da20..0000000 --- a/modules/frontend/src/pages/UsersIdPage/UsersIdPage.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// pages/UserPage/UserPage.tsx -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import { DefaultService } from "../../api/services/DefaultService"; -import { SearchBar } from "../../components/SearchBar/SearchBar"; -import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; -import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; -import { ListView } from "../../components/ListView/ListView"; -import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; -import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; -import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; - -const PAGE_SIZE = 10; - -type UsersIdPageProps = { - userId?: string; -}; - -export default function UsersIdPage({ userId }: UsersIdPageProps) { - const params = useParams(); - const id = userId || params?.id; - - const [user, setUser] = useState(null); - const [loadingUser, setLoadingUser] = useState(true); - const [errorUser, setErrorUser] = useState(null); - - // Для списка тайтлов - const [titles, setTitles] = useState([]); - const [nextPage, setNextPage] = useState([]); - const [cursor, setCursor] = useState(null); - const [loadingTitles, setLoadingTitles] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [search, setSearch] = useState(""); - const [sort, setSort] = useState("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 ( -
- - {/* --- Карточка пользователя --- */} - {loadingUser &&
Loading user...
} - {errorUser &&
{errorUser}
} - {user && ( -
- {user.nickname} -

{user.disp_name || user.nickname}

- {user.mail &&

{user.mail}

} - {user.user_desc &&

{user.user_desc}

} - {user.creation_date &&

Registered: {new Date(user.creation_date).toLocaleDateString()}

} -
- )} - - {/* --- Панель поиска, сортировки и лейаута --- */} -
- - - -
- - {/* --- Список тайтлов --- */} - {loadingTitles &&
Loading titles...
} - {!loadingTitles && titles.length === 0 &&
No titles found.
} - - {titles.length > 0 && ( - <> - - items={titles} - layout={layout} - hasMore={!!cursor || nextPage.length > 1} - loadingMore={loadingMore} - onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? : - } - /> - - {!cursor && nextPage.length === 0 && ( -
- Результатов больше нет, было найдено {titles.length} тайтлов. -
- )} - - )} -
- ); -} From de22dbfb504897da78c1ef60479708ee183530c7 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 10:01:52 +0300 Subject: [PATCH 07/14] feat: title cards linked to title pages --- modules/frontend/src/api/core/OpenAPI.ts | 2 +- .../src/pages/TitlesPage/TitlesPage.module.css | 1 - modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++----- modules/frontend/src/pages/UserPage/UserPage.tsx | 11 ++++++----- 4 files changed, 13 insertions(+), 12 deletions(-) delete mode 100644 modules/frontend/src/pages/TitlesPage/TitlesPage.module.css diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css deleted file mode 100644 index f1d8c73..0000000 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 0fec3c8..c9911b9 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -135,11 +136,11 @@ const handleLoadMore = async () => { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" - ? - : - } + renderItem={(title, layout) => ( + + {layout === "square" ? : } + + )} /> {!cursor && nextPage.length == 0 && ( diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 5fbd6b8..494ba99 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -9,6 +9,7 @@ import { ListView } from "../../components/ListView/ListView"; import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -129,8 +130,6 @@ export default function UserPage({ userId }: UserPageProps) { setLoadingMore(false); }; - // const getAvatarUrl = (avatarId?: number) => (avatarId ? `/api/images/${avatarId}` : "/default-avatar.png"); - return (
@@ -166,9 +165,11 @@ export default function UserPage({ userId }: UserPageProps) { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" ? : - } + renderItem={(title, layout) => ( + + {layout === "square" ? : } + + )} /> {!cursor && nextPage.length === 0 && ( From ad1c567b42793e743dad1268aac27cec7263508d Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 11:59:49 +0300 Subject: [PATCH 08/14] feat: added GetUserTitle route --- api/_build/openapi.yaml | 49 ++- api/api.gen.go | 708 +++++++++++++++++------------- api/openapi.yaml | 2 + api/paths/users-id-titles-id.yaml | 107 +++++ api/paths/users-id-titles.yaml | 84 +--- modules/backend/handlers/users.go | 54 ++- modules/backend/queries.sql | 31 +- sql/queries.sql.go | 100 +++++ 8 files changed, 733 insertions(+), 402 deletions(-) create mode 100644 api/paths/users-id-titles-id.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 2ee6cdc..424e893 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -220,6 +220,7 @@ paths: description: Unknown server error '/users/{user_id}/titles': get: + operationId: getUserTitles summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' @@ -360,6 +361,38 @@ paths: description: Conflict — title already assigned to user (if applicable) '500': description: Internal server error + '/users/{user_id}/titles/{title_id}': + get: + operationId: getUserTitle + summary: Get user title + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error patch: operationId: updateUserTitle summary: Update a usertitle @@ -367,12 +400,16 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - example: 123 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 requestBody: required: true content: @@ -380,16 +417,11 @@ paths: schema: type: object properties: - title_id: - type: integer - format: int64 status: $ref: '#/components/schemas/UserTitleStatus' rate: type: integer format: int32 - required: - - title_id responses: '200': description: Title successfully updated @@ -414,13 +446,12 @@ paths: parameters: - name: user_id in: path - description: ID of the user to assign the title to required: true schema: type: integer format: int64 - name: title_id - in: query + in: path required: true schema: type: integer diff --git a/api/api.gen.go b/api/api.gen.go index 6208050..32ab199 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -218,13 +218,8 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// DeleteUserTitleParams defines parameters for DeleteUserTitle. -type DeleteUserTitleParams struct { - TitleId int64 `form:"title_id" json:"title_id"` -} - -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { +// GetUserTitlesParams defines parameters for GetUserTitles. +type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` @@ -241,15 +236,6 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. -type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` - - // Status User's title status - Status *UserTitleStatus `json:"status,omitempty"` - TitleId int64 `json:"title_id"` -} - // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { Rate *int32 `json:"rate,omitempty"` @@ -259,15 +245,23 @@ type AddUserTitleJSONBody struct { TitleId int64 `json:"title_id"` } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` +} + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody -// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. -type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody - // AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. type AddUserTitleJSONRequestBody AddUserTitleJSONBody +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody + // ServerInterface represents all server handlers. type ServerInterface interface { // Get titles @@ -282,18 +276,21 @@ type ServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(c *gin.Context, userId int64) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(c *gin.Context, userId int64, params DeleteUserTitleParams) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(c *gin.Context, userId int64) + GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(c *gin.Context, userId int64, titleId int64) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(c *gin.Context, userId int64, titleId int64) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(c *gin.Context, userId int64, titleId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -505,50 +502,8 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { siw.Handler.UpdateUser(c, userId) } -// DeleteUserTitle operation middleware -func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteUserTitleParams - - // ------------- Required query parameter "title_id" ------------- - - if paramValue := c.Query("title_id"); paramValue != "" { - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument title_id is required, but not found"), http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "title_id", c.Request.URL.Query(), ¶ms.TitleId) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteUserTitle(c, userId, params) -} - -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { +// GetUserTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitles(c *gin.Context) { var err error @@ -562,7 +517,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams + var params GetUserTitlesParams // ------------- Optional query parameter "cursor" ------------- @@ -667,31 +622,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } } - siw.Handler.GetUsersUserIdTitles(c, userId, params) -} - -// UpdateUserTitle operation middleware -func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { - - var err error - - // ------------- Path parameter "user_id" ------------- - var userId int64 - - err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.UpdateUserTitle(c, userId) + siw.Handler.GetUserTitles(c, userId, params) } // AddUserTitle operation middleware @@ -718,6 +649,105 @@ func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { siw.Handler.AddUserTitle(c, userId) } +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId, titleId) +} + +// GetUserTitle operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUserTitle(c, userId, titleId) +} + +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId, titleId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -749,10 +779,11 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) - router.DELETE(options.BaseURL+"/users/:user_id/titles", wrapper.DeleteUserTitle) - router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) - router.PATCH(options.BaseURL+"/users/:user_id/titles", wrapper.UpdateUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) + router.DELETE(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.DeleteUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.GetUserTitle) + router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } type GetTitlesRequestObject struct { @@ -967,162 +998,55 @@ func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWri return nil } -type DeleteUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Params DeleteUserTitleParams -} - -type DeleteUserTitleResponseObject interface { - VisitDeleteUserTitleResponse(w http.ResponseWriter) error -} - -type DeleteUserTitle200Response struct { -} - -func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(200) - return nil -} - -type DeleteUserTitle401Response struct { -} - -func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type DeleteUserTitle403Response struct { -} - -func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type DeleteUserTitle404Response struct { -} - -func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type DeleteUserTitle500Response struct { -} - -func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type GetUsersUserIdTitlesRequestObject struct { +type GetUserTitlesRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams + Params GetUserTitlesParams } -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +type GetUserTitlesResponseObject interface { + VisitGetUserTitlesResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse struct { +type GetUserTitles200JSONResponse struct { Cursor CursorObj `json:"cursor"` Data []UserTitle `json:"data"` } -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles200JSONResponse) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserIdTitles204Response struct { +type GetUserTitles204Response struct { } -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles204Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetUsersUserIdTitles400Response struct { +type GetUserTitles400Response struct { } -func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles400Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserIdTitles404Response struct { +type GetUserTitles404Response struct { } -func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles404Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserIdTitles500Response struct { +type GetUserTitles500Response struct { } -func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { - w.WriteHeader(500) - return nil -} - -type UpdateUserTitleRequestObject struct { - UserId int64 `json:"user_id"` - Body *UpdateUserTitleJSONRequestBody -} - -type UpdateUserTitleResponseObject interface { - VisitUpdateUserTitleResponse(w http.ResponseWriter) error -} - -type UpdateUserTitle200JSONResponse UserTitleMini - -func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type UpdateUserTitle400Response struct { -} - -func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(400) - return nil -} - -type UpdateUserTitle401Response struct { -} - -func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(401) - return nil -} - -type UpdateUserTitle403Response struct { -} - -func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(403) - return nil -} - -type UpdateUserTitle404Response struct { -} - -func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil -} - -type UpdateUserTitle500Response struct { -} - -func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { +func (response GetUserTitles500Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1193,6 +1117,164 @@ func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.Respons return nil } +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type GetUserTitleResponseObject interface { + VisitGetUserTitleResponse(w http.ResponseWriter) error +} + +type GetUserTitle200JSONResponse UserTitleMini + +func (response GetUserTitle200JSONResponse) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserTitle204Response struct { +} + +func (response GetUserTitle204Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUserTitle400Response struct { +} + +func (response GetUserTitle400Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUserTitle404Response struct { +} + +func (response GetUserTitle404Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUserTitle500Response struct { +} + +func (response GetUserTitle500Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1207,18 +1289,21 @@ type StrictServerInterface interface { // Partially update a user account // (PATCH /users/{user_id}) UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) - // Delete a usertitle - // (DELETE /users/{user_id}/titles) - DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) // Get user titles // (GET /users/{user_id}/titles) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) - // Update a usertitle - // (PATCH /users/{user_id}/titles) - UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) + GetUserTitles(ctx context.Context, request GetUserTitlesRequestObject) (GetUserTitlesResponseObject, error) // Add a title to a user // (POST /users/{user_id}/titles) AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(ctx context.Context, request GetUserTitleRequestObject) (GetUserTitleResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1351,18 +1436,18 @@ func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { } } -// DeleteUserTitle operation middleware -func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params DeleteUserTitleParams) { - var request DeleteUserTitleRequestObject +// GetUserTitles operation middleware +func (sh *strictHandler) GetUserTitles(ctx *gin.Context, userId string, params GetUserTitlesParams) { + var request GetUserTitlesRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + return sh.ssi.GetUserTitles(ctx, request.(GetUserTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteUserTitle") + handler = middleware(handler, "GetUserTitles") } response, err := handler(ctx, request) @@ -1370,71 +1455,8 @@ func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { - if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject - - request.UserId = userId - request.Params = params - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { - ctx.Error(err) - } - } else if response != nil { - ctx.Error(fmt.Errorf("unexpected response type: %T", response)) - } -} - -// UpdateUserTitle operation middleware -func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64) { - var request UpdateUserTitleRequestObject - - request.UserId = userId - - var body UpdateUserTitleJSONRequestBody - if err := ctx.ShouldBindJSON(&body); err != nil { - ctx.Status(http.StatusBadRequest) - ctx.Error(err) - return - } - request.Body = &body - - handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "UpdateUserTitle") - } - - response, err := handler(ctx, request) - - if err != nil { - ctx.Error(err) - ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { - if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUserTitlesResponseObject); ok { + if err := validResponse.VisitGetUserTitlesResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1476,3 +1498,95 @@ func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUserTitle operation middleware +func (sh *strictHandler) GetUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request GetUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUserTitle(ctx, request.(GetUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUserTitleResponseObject); ok { + if err := validResponse.VisitGetUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/api/openapi.yaml b/api/openapi.yaml index 23f2058..08a4d54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -15,6 +15,8 @@ paths: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + /users/{user_id}/titles/{title_id}: + $ref: "./paths/users-id-titles-id.yaml" components: parameters: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml new file mode 100644 index 0000000..b4ad884 --- /dev/null +++ b/api/paths/users-id-titles-id.yaml @@ -0,0 +1,107 @@ +get: + summary: Get user title + operationId: getUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cb7092..75f5461 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -1,5 +1,6 @@ get: summary: Get user titles + operationId: getUserTitles parameters: - $ref: '../parameters/cursor.yaml' - $ref: "../parameters/title_sort.yaml" @@ -138,88 +139,5 @@ post: description: User or Title not found '409': description: Conflict — title already assigned to user (if applicable) - '500': - description: Internal server error - -patch: - summary: Update a usertitle - description: User updating title list of watched - operationId: updateUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - example: 123 - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - title_id - properties: - title_id: - type: integer - format: int64 - status: - $ref: '../schemas/enums/UserTitleStatus.yaml' - rate: - type: integer - format: int32 - - responses: - '200': - description: Title successfully updated - content: - application/json: - schema: - $ref: '../schemas/UserTitleMini.yaml' - '400': - description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to update title - '404': - description: User or Title not found - '500': - description: Internal server error - -delete: - summary: Delete a usertitle - description: User deleting title from list of watched - operationId: deleteUserTitle - parameters: - - name: user_id - in: path - required: true - schema: - type: integer - format: int64 - description: ID of the user to assign the title to - - in: query - name: title_id - required: true - schema: - type: integer - format: int64 - - - responses: - '200': - description: Title successfully deleted - # '400': - # description: Invalid request body (missing fields, invalid types, etc.) - '401': - description: Unauthorized — missing or invalid auth token - '403': - description: Forbidden — user not allowed to delete title - '404': - description: User or Title not found '500': description: Internal server error \ No newline at end of file diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 563a244..8723d16 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -204,7 +204,7 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o return oapi_usertitle, nil } -func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { +func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) { oapi_usertitles := make([]oapi.UserTitle, 0) @@ -213,7 +213,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } // var statuses_sort []string @@ -227,19 +227,19 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } title_statuses, err := TitleStatus2Sqlc(request.Params.Status) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, err + return oapi.GetUserTitles400Response{}, err } userID, err := parseInt64(request.UserId) if err != nil { log.Errorf("get user titles: %v", err) - return oapi.GetUsersUserIdTitles404Response{}, err + return oapi.GetUserTitles404Response{}, err } params := sqlc.SearchUserTitlesParams{ UserID: userID, @@ -265,7 +265,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles400Response{}, nil + return oapi.GetUserTitles400Response{}, nil } } } @@ -273,10 +273,10 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU titles, err := s.db.SearchUserTitles(ctx, params) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } if len(titles) == 0 { - return oapi.GetUsersUserIdTitles204Response{}, nil + return oapi.GetUserTitles204Response{}, nil } var new_cursor oapi.CursorObj @@ -286,7 +286,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU t, err := s.mapUsertitle(ctx, title) if err != nil { log.Errorf("%v", err) - return oapi.GetUsersUserIdTitles500Response{}, nil + return oapi.GetUserTitles500Response{}, nil } oapi_usertitles = append(oapi_usertitles, t) @@ -303,7 +303,7 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU } } - return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil + return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } func EmailToStringPtr(e *types.Email) *string { @@ -402,7 +402,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { params := sqlc.DeleteUserTitleParams{ UserID: request.UserId, - TitleID: request.Params.TitleId, + TitleID: request.TitleId, } _, err := s.db.DeleteUserTitle(ctx, params) if err != nil { @@ -427,7 +427,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Status: status, Rate: request.Body.Rate, UserID: request.UserId, - TitleID: request.Body.TitleId, + TitleID: request.TitleId, } user_title, err := s.db.UpdateUserTitle(ctx, params) @@ -455,3 +455,33 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil } + +func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) { + user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{ + TitleID: request.TitleId, + UserID: request.UserId, + }) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUserTitle404Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: *user_title.ID, + UserId: user_title.UserID, + } + + return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 5ac2c5c..1a90cde 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -394,4 +394,33 @@ RETURNING *; DELETE FROM usertitles WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') -RETURNING *; \ No newline at end of file +RETURNING *; + +-- name: GetUserTitleByID :one +SELECT + ut.*, + t.*, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 9338717..f35007d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -262,6 +262,106 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er return i, err } +const getUserTitleByID = `-- name: GetUserTitleByID :one +SELECT + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM usertitles as ut +LEFT JOIN users as u ON (ut.user_id = u.id) +LEFT JOIN titles as t ON (ut.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = $1::bigint AND u.id = $2::bigint +GROUP BY + t.id, i.id, s.id, si.id +` + +type GetUserTitleByIDParams struct { + TitleID int64 `json:"title_id"` + UserID int64 `json:"user_id"` +} + +type GetUserTitleByIDRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` + ID *int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID *int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus *TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { + row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) + var i GetUserTitleByIDRow + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, + ) + return i, err +} + const insertStudio = `-- name: InsertStudio :one INSERT INTO studios (studio_name, illust_id, studio_desc) VALUES ( From 13342d5613e20c9bf4ece9700ff085de8029b090 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 13:18:19 +0300 Subject: [PATCH 09/14] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 87f3655..adbe61e 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -18,9 +18,6 @@ jobs: - uses: actions/setup-go@v6 with: go-version: '^1.25' - check-latest: false - cache-dependency-path: | - go.sum - name: Build backend run: | From 3f0456ba01b20b016e0bf9142eb44a605a770f98 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 14:09:13 +0300 Subject: [PATCH 10/14] cicd: updated --- .forgejo/workflows/build-and-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index adbe61e..3c473d2 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -101,7 +101,7 @@ jobs: tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest deploy: - runs-on: self-hosted + runs-on: debian-test needs: build env: POSTGRES_USER: ${{ secrets.POSTGRES_USER }} From 8a3e14a5e5c0495be790ab1dbcd4832fd0f41fb0 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 16:26:03 +0300 Subject: [PATCH 11/14] feat: TitleStatusControls --- .../src/api/services/DefaultService.ts | 62 +++++++++++- modules/frontend/src/auth/core/OpenAPI.ts | 2 +- .../TitleStatusControls.tsx | 88 +++++++++++++++++ .../src/pages/TitlePage/TitlePage.tsx | 94 ++++++------------- .../frontend/src/pages/UserPage/UserPage.tsx | 2 +- 5 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 5070fae..218b461 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -199,7 +199,7 @@ export class DefaultService { * @returns any List of user titles * @throws ApiError */ - public static getUsersTitles( + public static getUserTitles( userId: string, cursor?: string, sort?: TitleSort, @@ -278,27 +278,54 @@ export class DefaultService { }, }); } + /** + * Get user title + * @param userId + * @param titleId + * @returns UserTitleMini User titles + * @throws ApiError + */ + public static getUserTitle( + userId: number, + titleId: number, + ): CancelablePromise { + 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 { 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 { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to delete title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 79aa305..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/auth', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx new file mode 100644 index 0000000..0c9c741 --- /dev/null +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { DefaultService } from "../../api"; +import type { UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +// Статусы с иконками и подписью +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: , label: "Planned" }, + { status: "finished", icon: , label: "Finished" }, + { status: "in-progress", icon: , label: "In Progress" }, + { status: "dropped", icon: , label: "Dropped" }, +]; + +export function TitleStatusControls({ titleId }: { titleId: number }) { + const [currentStatus, setCurrentStatus] = useState(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 ( +
+ {STATUS_BUTTONS.map(btn => ( + + ))} +
+ ); +} diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 5ea0e3d..01f9c49 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,20 +1,8 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, Link } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { Title, UserTitleStatus } from "../../api"; -import { - ClockIcon, - CheckCircleIcon, - PlayCircleIcon, - XCircleIcon, -} from "@heroicons/react/24/solid"; - -const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ - { status: "planned", icon: , label: "Planned" }, - { status: "finished", icon: , label: "Finished" }, - { status: "in-progress", icon: , label: "In Progress" }, - { status: "dropped", icon: , 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(null); - const [userStatus, setUserStatus] = useState(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 (
- {/* Постер */} + {/* Poster + status buttons */}
- {/* Статус кнопки с иконками */} -
- {STATUS_BUTTONS.map(btn => ( - - ))} -
+ {/* Status buttons */} +
- {/* Информация о тайтле */} + {/* Title info */}

{title.title_names?.en?.[0] || "Untitled"}

- {title.studio &&

Studio: {title.studio.name}

} + + {title.studio && ( +

+ Studio:{" "} + {title.studio.id ? ( + + {title.studio.name} + + ) : ( + title.studio.name + )} +

+ )} + {title.title_status &&

Status: {title.title_status}

} + {title.rating !== undefined && (

Rating: {title.rating} ({title.rating_count} votes)

)} + {title.release_year && (

Released: {title.release_year} {title.release_season || ""}

)} + {title.episodes_aired !== undefined && (

Episodes: {title.episodes_aired}/{title.episodes_all}

)} + {title.tags && title.tags.length > 0 && (

Tags: {getTagsString()} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 494ba99..7cc0db5 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -63,7 +63,7 @@ export default function UserPage({ userId }: UserPageProps) { : ""; try { - const result = await DefaultService.getUsersTitles( + const result = await DefaultService.getUserTitles( id, cursorStr, sort, From 37cdc32d5da55d620cc82eb2caf3b6de28dcab57 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 27 Nov 2025 16:28:09 +0300 Subject: [PATCH 12/14] fix: fix GetUserTitleByID --- modules/backend/handlers/users.go | 2 +- modules/backend/main.go | 2 +- modules/backend/queries.sql | 27 +--------- sql/queries.sql.go | 82 ++----------------------------- 4 files changed, 8 insertions(+), 105 deletions(-) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 8723d16..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -479,7 +479,7 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque Rate: user_title.Rate, ReviewId: user_title.ReviewID, Status: oapi_status, - TitleId: *user_title.ID, + TitleId: user_title.TitleID, UserId: user_title.UserID, } diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..8f58ffe 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -48,7 +48,7 @@ func main() { r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 1a90cde..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -398,29 +398,6 @@ RETURNING *; -- name: GetUserTitleByID :one SELECT - ut.*, - t.*, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.* FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = sqlc.arg('title_id')::bigint AND u.id = sqlc.arg('user_id')::bigint -GROUP BY - t.id, i.id, s.id, si.id; \ No newline at end of file +WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index ddf6f6b..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -306,32 +306,9 @@ func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, const getUserTitleByID = `-- name: GetUserTitleByID :one SELECT - ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime, - t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type as title_storage_type, - i.image_path as title_image_path, - COALESCE( - jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), - '[]'::jsonb - )::jsonb as tag_names, - s.studio_name as studio_name, - s.illust_id as studio_illust_id, - s.studio_desc as studio_desc, - si.storage_type as studio_storage_type, - si.image_path as studio_image_path - + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime FROM usertitles as ut -LEFT JOIN users as u ON (ut.user_id = u.id) -LEFT JOIN titles as t ON (ut.title_id = t.id) -LEFT JOIN images as i ON (t.poster_id = i.id) -LEFT JOIN title_tags as tt ON (t.id = tt.title_id) -LEFT JOIN tags as g ON (tt.tag_id = g.id) -LEFT JOIN studios as s ON (t.studio_id = s.id) -LEFT JOIN images as si ON (s.illust_id = si.id) - -WHERE t.id = $1::bigint AND u.id = $2::bigint -GROUP BY - t.id, i.id, s.id, si.id +WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint ` type GetUserTitleByIDParams struct { @@ -339,39 +316,9 @@ type GetUserTitleByIDParams struct { UserID int64 `json:"user_id"` } -type GetUserTitleByIDRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime time.Time `json:"ctime"` - ID *int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID *int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus *TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` - TitleStorageType *StorageTypeT `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` - StudioIllustID *int64 `json:"studio_illust_id"` - StudioDesc *string `json:"studio_desc"` - StudioStorageType *StorageTypeT `json:"studio_storage_type"` - StudioImagePath *string `json:"studio_image_path"` -} - -func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (GetUserTitleByIDRow, error) { +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) - var i GetUserTitleByIDRow + var i Usertitle err := row.Scan( &i.UserID, &i.TitleID, @@ -379,27 +326,6 @@ func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDPara &i.Rate, &i.ReviewID, &i.Ctime, - &i.ID, - &i.TitleNames, - &i.StudioID, - &i.PosterID, - &i.TitleStatus, - &i.Rating, - &i.RatingCount, - &i.ReleaseYear, - &i.ReleaseSeason, - &i.Season, - &i.EpisodesAired, - &i.EpisodesAll, - &i.EpisodesLen, - &i.TitleStorageType, - &i.TitleImagePath, - &i.TagNames, - &i.StudioName, - &i.StudioIllustID, - &i.StudioDesc, - &i.StudioStorageType, - &i.StudioImagePath, ) return i, err } From f71c1f4f082bfd6914cfcf2d3f879e3b3b7b05db Mon Sep 17 00:00:00 2001 From: nihonium Date: Fri, 28 Nov 2025 11:43:10 +0300 Subject: [PATCH 13/14] feat: added rabbitmq --- deploy/docker-compose.yml | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7f53da5..79ad2f5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,20 +11,34 @@ services: - "${POSTGRES_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql + networks: + - nyanimedb-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s - # pgadmin: - # image: dpage/pgadmin4:${PGADMIN_VERSION} - # container_name: pgadmin - # restart: always - # environment: - # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - # ports: - # - "${PGADMIN_PORT}:80" - # depends_on: - # - postgres - # volumes: - # - pgadmin_data:/var/lib/pgadmin + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - nyanimedb-network + healthcheck: + test: ["CMD", "rabbitmqctl", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest @@ -37,6 +51,9 @@ services: - "8080:8080" depends_on: - postgres + - rabbitmq + networks: + - nyanimedb-network nyanimedb-auth: image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest @@ -49,6 +66,8 @@ services: - "8082:8082" depends_on: - postgres + networks: + - nyanimedb-network nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest @@ -58,7 +77,12 @@ services: - "8081:80" depends_on: - nyanimedb-backend + networks: + - nyanimedb-network volumes: postgres_data: - pgadmin_data: + rabbitmq_data: + +networks: + nyanimedb-network: From 1756d61da466a70fdbe0f3dce4b963f197fc92c9 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sun, 30 Nov 2025 00:51:38 +0300 Subject: [PATCH 14/14] lib for rabbitMQ --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 7b7cc71..fe4f31e 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/streadway/amqp v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index cd197e6..6704a5a 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=