diff --git a/Dockerfiles/Dockerfile_auth b/Dockerfiles/Dockerfile_auth new file mode 100644 index 0000000..5280e86 --- /dev/null +++ b/Dockerfiles/Dockerfile_auth @@ -0,0 +1,6 @@ +FROM ubuntu:22.04 + +WORKDIR /app +COPY --chmod=755 modules/auth/auth /app +EXPOSE 8082 +ENTRYPOINT ["/app/auth"] \ No newline at end of file diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 215eabc..6b39558 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -141,7 +141,82 @@ paths: description: User not found '500': description: Unknown server error - '/users/{user_id}/titles/': + patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)' + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)' + '404': + description: User not found + '409': + description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user' + '422': + description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' + '500': + description: Unknown server error + '/users/{user_id}/titles': get: summary: Get user titles parameters: @@ -229,8 +304,74 @@ paths: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error + post: + summary: Add a title to a user + description: 'User adding title to list af watched, status required' + operationId: addUserTitle + parameters: + - name: user_id + in: path + 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: + $ref: '#/components/schemas/UserTitle' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: false + '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 assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error components: parameters: cursor: @@ -273,6 +414,10 @@ components: format: int64 storage_type: type: string + description: Image storage type + enum: + - s3 + - local image_path: type: string TitleStatus: @@ -409,11 +554,8 @@ components: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '#/components/schemas/Image' mail: type: string format: email diff --git a/api/api.gen.go b/api/api.gen.go index dcc2f89..f3e935c 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,12 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for ImageStorageType. +const ( + Local ImageStorageType = "local" + S3 ImageStorageType = "s3" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -55,11 +61,16 @@ type CursorObj struct { // Image defines model for Image. type Image struct { - Id *int64 `json:"id,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - StorageType *string `json:"storage_type,omitempty"` + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + + // StorageType Image storage type + StorageType *ImageStorageType `json:"storage_type,omitempty"` } +// ImageStorageType Image storage type +type ImageStorageType string + // ReleaseSeason Title release season type ReleaseSeason string @@ -113,9 +124,6 @@ type TitleStatus string // User defines model for User. type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id,omitempty"` - // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -123,7 +131,8 @@ type User struct { DispName *string `json:"disp_name,omitempty"` // Id Unique user ID (primary key) - Id *int64 `json:"id,omitempty"` + Id *int64 `json:"id,omitempty"` + Image *Image `json:"image,omitempty"` // Mail User email Mail *openapi_types.Email `json:"mail,omitempty"` @@ -181,6 +190,24 @@ type GetUsersUserIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONBody defines parameters for UpdateUser. +type UpdateUserJSONBody struct { + // AvatarId ID of the user avatar (references `images.id`); set to `null` to remove avatar + AvatarId *int64 `json:"avatar_id"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Mail User email (must be unique and valid) + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + `_` or `-`, 3–16 chars) + Nickname *string `json:"nickname,omitempty"` + + // UserDesc User description / bio + UserDesc *string `json:"user_desc,omitempty"` +} + // GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. type GetUsersUserIdTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -199,6 +226,12 @@ type GetUsersUserIdTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. +type UpdateUserJSONRequestBody UpdateUserJSONBody + +// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. +type AddUserTitleJSONRequestBody = UserTitle + // Getter for additional properties for Title. Returns the specified // element and whether it was found func (a Title) Get(fieldName string) (value interface{}, found bool) { @@ -591,9 +624,15 @@ type ServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(c *gin.Context, userId int64) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(c *gin.Context, userId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -781,6 +820,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { siw.Handler.GetUsersUserId(c, userId, params) } +// UpdateUser operation middleware +func (siw *ServerInterfaceWrapper) UpdateUser(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.UpdateUser(c, userId) +} + // GetUsersUserIdTitles operation middleware func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { @@ -904,6 +967,30 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { siw.Handler.GetUsersUserIdTitles(c, userId, params) } +// AddUserTitle operation middleware +func (siw *ServerInterfaceWrapper) AddUserTitle(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.AddUserTitle(c, userId) +} + // GinServerOptions provides options for the Gin server. type GinServerOptions struct { BaseURL string @@ -934,7 +1021,9 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUsersUserIdTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) } type GetTitlesRequestObject struct { @@ -1075,6 +1164,80 @@ func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.Res return nil } +type UpdateUserRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserJSONRequestBody +} + +type UpdateUserResponseObject interface { + VisitUpdateUserResponse(w http.ResponseWriter) error +} + +type UpdateUser200JSONResponse User + +func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUser400Response struct { +} + +func (response UpdateUser400Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUser401Response struct { +} + +func (response UpdateUser401Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUser403Response struct { +} + +func (response UpdateUser403Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUser404Response struct { +} + +func (response UpdateUser404Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUser409Response struct { +} + +func (response UpdateUser409Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type UpdateUser422Response struct { +} + +func (response UpdateUser422Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(422) + return nil +} + +type UpdateUser500Response struct { +} + +func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersUserIdTitlesRequestObject struct { UserId string `json:"user_id"` Params GetUsersUserIdTitlesParams @@ -1112,6 +1275,14 @@ func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesRespons return nil } +type GetUsersUserIdTitles404Response struct { +} + +func (response GetUsersUserIdTitles404Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + type GetUsersUserIdTitles500Response struct { } @@ -1120,6 +1291,83 @@ func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesRespons return nil } +type AddUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *AddUserTitleJSONRequestBody +} + +type AddUserTitleResponseObject interface { + VisitAddUserTitleResponse(w http.ResponseWriter) error +} + +type AddUserTitle200JSONResponse struct { + Data *struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + } `json:"data,omitempty"` +} + +func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddUserTitle400Response struct { +} + +func (response AddUserTitle400Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type AddUserTitle401Response struct { +} + +func (response AddUserTitle401Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type AddUserTitle403Response struct { +} + +func (response AddUserTitle403Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type AddUserTitle404Response struct { +} + +func (response AddUserTitle404Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type AddUserTitle409Response struct { +} + +func (response AddUserTitle409Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type AddUserTitle500Response struct { +} + +func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get titles @@ -1131,9 +1379,15 @@ type StrictServerInterface interface { // Get user info // (GET /users/{user_id}) GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) // Get user titles - // (GET /users/{user_id}/titles/) + // (GET /users/{user_id}/titles) GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1231,6 +1485,41 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params } } +// UpdateUser operation middleware +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { + var request UpdateUserRequestObject + + request.UserId = userId + + var body UpdateUserJSONRequestBody + 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.UpdateUser(ctx, request.(UpdateUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUser") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserResponseObject); ok { + if err := validResponse.VisitUpdateUserResponse(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 @@ -1258,3 +1547,38 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p ctx.Error(fmt.Errorf("unexpected response type: %T", response)) } } + +// AddUserTitle operation middleware +func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { + var request AddUserTitleRequestObject + + request.UserId = userId + + var body AddUserTitleJSONRequestBody + 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.AddUserTitle(ctx, request.(AddUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(AddUserTitleResponseObject); ok { + if err := validResponse.VisitAddUserTitleResponse(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 c8bdbc4..7da26f8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -13,8 +13,9 @@ paths: $ref: "./paths/titles-id.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" - /users/{user_id}/titles/: + /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index a76cc40..23ea761 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -85,5 +85,59 @@ get: description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error + +post: + summary: Add a title to a user + description: User adding title to list af watched, status required + operationId: addUserTitle + parameters: + - name: user_id + in: path + 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: + $ref: '../schemas/UserTitle.yaml' + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + type: object + properties: + # success: + # type: boolean + # example: true + # error: + # type: string + # nullable: true + # example: null + data: + $ref: '../schemas/UserTitleMini.yaml' + # required: + # - success + # - error + '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 assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0acdb81..06f4a19 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -24,3 +24,79 @@ get: description: Request params are not correct '500': description: Unknown server error + +patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: Username (alphanumeric + `_` or `-`, 3–16 chars) + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '400': + description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + '404': + description: User not found + '409': + description: Conflict — e.g., requested `nickname` or `mail` already taken by another user + '422': + description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + '500': + description: Unknown server error diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 4ae3cb7..3fb520b 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -5,6 +5,6 @@ properties: type: integer format: int64 storage_type: - type: string + $ref: './enums/StorageType.yaml' image_path: type: string diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml index 8b4d88d..4e53534 100644 --- a/api/schemas/User.yaml +++ b/api/schemas/User.yaml @@ -5,11 +5,8 @@ properties: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '../schemas/Image.yaml' mail: type: string format: email diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml new file mode 100644 index 0000000..9e45e95 --- /dev/null +++ b/api/schemas/UserTitleMini.yaml @@ -0,0 +1,24 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time +additionalProperties: false diff --git a/api/schemas/enums/StorageType.yaml b/api/schemas/enums/StorageType.yaml new file mode 100644 index 0000000..1984a87 --- /dev/null +++ b/api/schemas/enums/StorageType.yaml @@ -0,0 +1,5 @@ +type: string +description: Image storage type +enum: + - s3 + - local \ No newline at end of file diff --git a/api/schemas/updateUser.yaml b/api/schemas/updateUser.yaml new file mode 100644 index 0000000..e1d77af --- /dev/null +++ b/api/schemas/updateUser.yaml @@ -0,0 +1,26 @@ +type: object +properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. +additionalProperties: false +description: Only provided fields are updated. Omitted fields remain unchanged. \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go new file mode 100644 index 0000000..adb2b06 --- /dev/null +++ b/auth/auth.gen.go @@ -0,0 +1,249 @@ +// Package auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignIn401JSONResponse struct { + Error *string `json:"error,omitempty"` +} + +func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/auth/auth.gen.go b/auth/auth/auth.gen.go new file mode 100644 index 0000000..12b6622 --- /dev/null +++ b/auth/auth/auth.gen.go @@ -0,0 +1,329 @@ +// Package oapi_auth provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +package oapi_auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" +) + +// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. +type PostAuthSignInJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. +type PostAuthSignUpJSONBody struct { + Nickname string `json:"nickname"` + Pass string `json:"pass"` +} + +// PostAuthVerifyTokenJSONBody defines parameters for PostAuthVerifyToken. +type PostAuthVerifyTokenJSONBody struct { + // Token JWT token to validate + Token string `json:"token"` +} + +// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. +type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody + +// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. +type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody + +// PostAuthVerifyTokenJSONRequestBody defines body for PostAuthVerifyToken for application/json ContentType. +type PostAuthVerifyTokenJSONRequestBody PostAuthVerifyTokenJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(c *gin.Context) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(c *gin.Context) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(c *gin.Context) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// PostAuthSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignIn(c) +} + +// PostAuthSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthSignUp(c) +} + +// PostAuthVerifyToken operation middleware +func (siw *ServerInterfaceWrapper) PostAuthVerifyToken(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAuthVerifyToken(c) +} + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + errorHandler := options.ErrorHandler + if errorHandler == nil { + errorHandler = func(c *gin.Context, err error, statusCode int) { + c.JSON(statusCode, gin.H{"msg": err.Error()}) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandler: errorHandler, + } + + router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) + router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/auth/verify-token", wrapper.PostAuthVerifyToken) +} + +type PostAuthSignInRequestObject struct { + Body *PostAuthSignInJSONRequestBody +} + +type PostAuthSignInResponseObject interface { + VisitPostAuthSignInResponse(w http.ResponseWriter) error +} + +type PostAuthSignIn200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + + // Token JWT token to access protected endpoints + Token *string `json:"token"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthSignUpRequestObject struct { + Body *PostAuthSignUpJSONRequestBody +} + +type PostAuthSignUpResponseObject interface { + VisitPostAuthSignUpResponse(w http.ResponseWriter) error +} + +type PostAuthSignUp200JSONResponse struct { + Error *string `json:"error"` + Success *bool `json:"success,omitempty"` + UserId *string `json:"user_id"` +} + +func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostAuthVerifyTokenRequestObject struct { + Body *PostAuthVerifyTokenJSONRequestBody +} + +type PostAuthVerifyTokenResponseObject interface { + VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error +} + +type PostAuthVerifyToken200JSONResponse struct { + // Error Error message if token is invalid + Error *string `json:"error"` + + // UserId User ID extracted from token if valid + UserId *string `json:"user_id"` + + // Valid True if token is valid + Valid *bool `json:"valid,omitempty"` +} + +func (response PostAuthVerifyToken200JSONResponse) VisitPostAuthVerifyTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Sign in a user and return JWT + // (POST /auth/sign-in) + PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // Sign up a new user + // (POST /auth/sign-up) + PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // Verify JWT validity + // (POST /auth/verify-token) + PostAuthVerifyToken(ctx context.Context, request PostAuthVerifyTokenRequestObject) (PostAuthVerifyTokenResponseObject, error) +} + +type StrictHandlerFunc = strictgin.StrictGinHandlerFunc +type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// PostAuthSignIn operation middleware +func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { + var request PostAuthSignInRequestObject + + var body PostAuthSignInJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignIn") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { + if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthSignUp operation middleware +func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { + var request PostAuthSignUpRequestObject + + var body PostAuthSignUpJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthSignUp") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { + if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// PostAuthVerifyToken operation middleware +func (sh *strictHandler) PostAuthVerifyToken(ctx *gin.Context) { + var request PostAuthVerifyTokenRequestObject + + var body PostAuthVerifyTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostAuthVerifyToken(ctx, request.(PostAuthVerifyTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostAuthVerifyToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostAuthVerifyTokenResponseObject); ok { + if err := validResponse.VisitPostAuthVerifyTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/auth/oapi-auth-codegen.yaml b/auth/oapi-auth-codegen.yaml new file mode 100644 index 0000000..6792391 --- /dev/null +++ b/auth/oapi-auth-codegen.yaml @@ -0,0 +1,6 @@ +package: auth +generate: + strict-server: true + gin-server: true + models: true +output: auth/auth.gen.go \ No newline at end of file diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml new file mode 100644 index 0000000..913c000 --- /dev/null +++ b/auth/openapi-auth.yaml @@ -0,0 +1,170 @@ +openapi: 3.1.1 +info: + title: Auth Service + version: 1.0.0 + +servers: + - url: /auth + +paths: + /auth/sign-up: + post: + summary: Sign up a new user + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-up result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + + /auth/sign-in: + post: + summary: Sign in a user and return JWT + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nickname, pass] + properties: + nickname: + type: string + pass: + type: string + format: password + responses: + "200": + description: Sign-in result with JWT + # headers: + # Set-Cookie: + # schema: + # type: array + # items: + # type: string + # explode: true + # style: simple + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + error: + type: string + nullable: true + user_id: + type: string + nullable: true + "401": + description: Access denied due to invalid credentials + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "Access denied" + # /auth/verify-token: + # post: + # summary: Verify JWT validity + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [token] + # properties: + # token: + # type: string + # description: JWT token to validate + # responses: + # "200": + # description: Token validation result + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if token is valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from token if valid + # error: + # type: string + # nullable: true + # description: Error message if token is invalid + # /auth/refresh-token: + # post: + # summary: Refresh JWT using a refresh token + # tags: [Auth] + # requestBody: + # required: true + # content: + # application/json: + # schema: + # type: object + # required: [refresh_token] + # properties: + # refresh_token: + # type: string + # description: JWT refresh token obtained from sign-in + # responses: + # "200": + # description: New access (and optionally refresh) token + # content: + # application/json: + # schema: + # type: object + # properties: + # valid: + # type: boolean + # description: True if refresh token was valid + # user_id: + # type: string + # nullable: true + # description: User ID extracted from refresh token + # access_token: + # type: string + # description: New access token + # nullable: true + # refresh_token: + # type: string + # description: New refresh token (optional) + # nullable: true + # error: + # type: string + # nullable: true + # description: Error message if refresh token is invalid diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1a96253..7f53da5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -37,6 +37,18 @@ services: - "8080:8080" depends_on: - postgres + + nyanimedb-auth: + image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + container_name: nyanimedb-auth + restart: always + environment: + LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} + ports: + - "8082:8082" + depends_on: + - postgres nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest diff --git a/deploy/generate.sh b/deploy/generate.sh index d7d94a2..29587cf 100644 --- a/deploy/generate.sh +++ b/deploy/generate.sh @@ -1,3 +1,3 @@ -npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file diff --git a/go.mod b/go.mod index 80a9ab1..bf73121 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/oapi-codegen/runtime v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 diff --git a/go.sum b/go.sum index 121ca40..8f46514 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go new file mode 100644 index 0000000..9b9b0d3 --- /dev/null +++ b/modules/auth/handlers/handlers.go @@ -0,0 +1,197 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "net/http" + auth "nyanimedb/auth" + sqlc "nyanimedb/sql" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +var 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 +} + +func NewServer(db *sqlc.Queries) Server { + return Server{db: db} +} + +func parseInt64(s string) (int32, error) { + i, err := strconv.ParseInt(s, 10, 64) + return int32(i), err +} + +func generateTokens(userID string) (accessToken string, refreshToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessToken, err = at.SignedString(accessSecret) + if err != nil { + return "", "", err + } + + refreshClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), + } + rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err = rt.SignedString(refreshSecret) + if err != nil { + return "", "", err + } + + return accessToken, refreshToken, nil +} + +func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { + err := "" + success := true + UserDb[req.Body.Nickname] = req.Body.Pass + + return auth.PostAuthSignUp200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + }, nil +} + +func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { + // ctx.SetCookie("122") + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + err := "" + success := true + + pass, ok := UserDb[req.Body.Nickname] + if !ok || pass != req.Body.Pass { + e := "invalid credentials" + return auth.PostAuthSignIn401JSONResponse{ + Error: &e, + }, nil + } + + accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + + ginCtx.SetSameSite(http.SameSiteStrictMode) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + + // Return access token; refresh token can be returned in response or HttpOnly cookie + result := auth.PostAuthSignIn200JSONResponse{ + Error: &err, + Success: &success, + UserId: &req.Body.Nickname, + } + return result, nil +} + +// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return accessSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// valid = true +// userID = &uid +// } else { +// e := "user_id not found in token" +// errStr = &e +// } +// } else { +// e := "invalid token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { +// valid := false +// var userID *string +// var errStr *string + +// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { +// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { +// return nil, fmt.Errorf("unexpected signing method") +// } +// return refreshSecret, nil +// }) + +// if err != nil { +// e := err.Error() +// errStr = &e +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } + +// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { +// if uid, ok := claims["user_id"].(string); ok { +// // Refresh token is valid, generate new tokens +// newAccessToken, newRefreshToken, _ := generateTokens(uid) +// valid = true +// userID = &uid +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: nil, +// Token: &newAccessToken, // return new access token +// // optionally return newRefreshToken as well +// }, nil +// } else { +// e := "user_id not found in refresh token" +// errStr = &e +// } +// } else { +// e := "invalid refresh token claims" +// errStr = &e +// } + +// return auth.PostAuthVerifyToken200JSONResponse{ +// Valid: &valid, +// UserId: userID, +// Error: errStr, +// }, nil +// } diff --git a/modules/auth/main.go b/modules/auth/main.go new file mode 100644 index 0000000..c001e8b --- /dev/null +++ b/modules/auth/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" + + auth "nyanimedb/auth" + handlers "nyanimedb/modules/auth/handlers" + sqlc "nyanimedb/sql" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +var AppConfig Config + +func main() { + r := gin.Default() + + var queries *sqlc.Queries = nil + + server := handlers.NewServer(queries) + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + auth.RegisterHandlers(r, auth.NewStrictHandler( + server, + []auth.StrictMiddlewareFunc{}, + )) + + r.Run(":8082") +} diff --git a/modules/auth/types.go b/modules/auth/types.go new file mode 100644 index 0000000..038b179 --- /dev/null +++ b/modules/auth/types.go @@ -0,0 +1,6 @@ +package main + +type Config struct { + JwtPrivateKey string + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` +} diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index e22ce3f..2cf2283 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -17,11 +17,27 @@ func NewServer(db *sqlc.Queries) Server { return Server{db: db} } +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.ImageStorageType, error) { + if s == nil { + return nil, nil + } + var t oapi.ImageStorageType + switch *s { + case sqlc.StorageTypeTLocal: + t = oapi.Local + case sqlc.StorageTypeTS3: + t = oapi.S3 + default: + return nil, fmt.Errorf("unexpected storage type: %s", *s) + } + return &t, nil +} + func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi.Title, error) { oapi_title := oapi.Title{ EpisodesAired: title.EpisodesAired, - EpisodesAll: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, // EpisodesLen: &episodes_lens, Id: title.ID, // Poster: &oapi_image, @@ -41,6 +57,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) } + oapi_title.TitleNames = title_names if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) @@ -56,6 +73,7 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if err != nil { return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) } + oapi_title.Tags = oapi_tag_names var oapi_studio oapi.Studio if title.StudioName != nil { @@ -68,7 +86,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. oapi_studio.Poster = &oapi.Image{} oapi_studio.Poster.Id = title.StudioIllustID oapi_studio.Poster.ImagePath = title.StudioImagePath - oapi_studio.Poster.StorageType = &title.StudioStorageType + + s, err := sql2StorageType(title.StudioStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err) + } + oapi_studio.Poster.StorageType = s + } } oapi_title.Studio = &oapi_studio @@ -78,8 +102,13 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. if title.PosterID != nil { oapi_image.Id = title.PosterID oapi_image.ImagePath = title.TitleImagePath - oapi_image.StorageType = &title.TitleStorageType + s, err := sql2StorageType(title.TitleStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err) + } + oapi_image.StorageType = s } + oapi_title.Poster = &oapi_image var release_season oapi.ReleaseSeason if title.ReleaseSeason != nil { @@ -96,9 +125,9 @@ func (s Server) mapTitle(ctx context.Context, title sqlc.GetTitleByIDRow) (oapi. return oapi_title, nil } -func parseInt64(s string) (int32, error) { +func parseInt64(s string) (int64, error) { i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err + return i, err } func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 054b745..c67177f 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -81,56 +81,56 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { +// func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image +// var oapi_image oapi.Image - sqlc_image, err := s.db.GetImageByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil //todo: error reference in db - } - return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) - } +// sqlc_image, err := s.db.GetImageByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil //todo: error reference in db +// } +// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) +// } - //can cast and dont use brain cause all this fields required in image table - oapi_image.Id = &sqlc_image.ID - oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) - oapi_image.StorageType = &storageTypeStr +// //can cast and dont use brain cause all this fields required in image table +// oapi_image.Id = &sqlc_image.ID +// oapi_image.ImagePath = &sqlc_image.ImagePath +// storageTypeStr := string(sqlc_image.StorageType) +// oapi_image.StorageType = string(storageTypeStr) - return &oapi_image, nil -} +// return &oapi_image, nil +// } -func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { +// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { - var oapi_studio oapi.Studio +// var oapi_studio oapi.Studio - sqlc_studio, err := s.db.GetStudioByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil - } - return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) - } +// sqlc_studio, err := s.db.GetStudioByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil +// } +// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) +// } - oapi_studio.Id = sqlc_studio.ID - oapi_studio.Name = sqlc_studio.StudioName - oapi_studio.Description = sqlc_studio.StudioDesc +// oapi_studio.Id = sqlc_studio.ID +// oapi_studio.Name = sqlc_studio.StudioName +// oapi_studio.Description = sqlc_studio.StudioDesc - if sqlc_studio.IllustID == nil { - return &oapi_studio, nil - } - oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) - if err != nil { - return &oapi_studio, fmt.Errorf("GetImage: %v", err) - } - if oapi_illust != nil { - oapi_studio.Poster = oapi_illust - } +// if sqlc_studio.IllustID == nil { +// return &oapi_studio, nil +// } +// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) +// if err != nil { +// return &oapi_studio, fmt.Errorf("GetImage: %v", err) +// } +// if oapi_illust != nil { +// oapi_studio.Poster = oapi_illust +// } - return &oapi_studio, nil -} +// return &oapi_studio, nil +// } func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { var oapi_title oapi.Title @@ -224,7 +224,8 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // EpisodesLen: title.EpisodesLen, TitleStorageType: title.TitleStorageType, TitleImagePath: title.TitleImagePath, - TagNames: title.TitleNames, + TitleNames: title.TitleNames, + TagNames: title.TagNames, StudioName: title.StudioName, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, @@ -232,6 +233,11 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // StudioImagePath: title.StudioImagePath, } + // if title.TitleStorageType != nil { + // s := *title.TitleStorageType + // _title.TitleStorageType = string(s) + // } + t, err := s.mapTitle(ctx, _title) if err != nil { log.Errorf("%v", err) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 3a271d7..927c1c1 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -27,16 +27,25 @@ import ( // return int32(i), err // } -func mapUser(u sqlc.GetUserByIDRow) oapi.User { +func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { + i := oapi.Image{ + Id: u.AvatarID, + ImagePath: u.ImagePath, + } + s, err := sql2StorageType(u.StorageType) + if err != nil { + return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) + } + i.StorageType = s return oapi.User{ - AvatarId: u.AvatarID, + Image: &i, CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, - Mail: (*types.Email)(u.Mail), + Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, - } + }, nil } func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { @@ -44,14 +53,19 @@ func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdReque if err != nil { return oapi.GetUsersUserId404Response{}, nil } - user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { return oapi.GetUsersUserId404Response{}, nil } return nil, err } - return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil + user, err := mapUser(_user) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersUserId500Response{}, err + } + return oapi.GetUsersUserId200JSONResponse(user), nil } func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { @@ -125,10 +139,32 @@ func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, e return sqlc_status, nil } +func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { + var sqlc_status sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + + switch *s { + case oapi.UserTitleStatusFinished: + sqlc_status = sqlc.UsertitleStatusTFinished + case oapi.UserTitleStatusInProgress: + sqlc_status = sqlc.UsertitleStatusTInProgress + case oapi.UserTitleStatusDropped: + sqlc_status = sqlc.UsertitleStatusTDropped + case oapi.UserTitleStatusPlanned: + sqlc_status = sqlc.UsertitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + + return &sqlc_status, nil +} + func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { oapi_usertitle := oapi.UserTitle{ - Ctime: sqlDate2oapi(t.UserCtime), + Ctime: &t.UserCtime, Rate: t.UserRate, ReviewId: t.ReviewID, // Status: , @@ -157,8 +193,9 @@ func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (o // EpisodesLen: title.EpisodesLen, TitleStorageType: t.TitleStorageType, TitleImagePath: t.TitleImagePath, - TagNames: t.TitleNames, StudioName: t.StudioName, + TitleNames: t.TitleNames, + TagNames: t.TagNames, // StudioIllustID: title.StudioIllustID, // StudioDesc: title.StudioDesc, // StudioStorageType: title.StudioStorageType, @@ -206,7 +243,13 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles400Response{}, err } + userID, err := parseInt64(request.UserId) + if err != nil { + log.Errorf("get user titles: %v", err) + return oapi.GetUsersUserIdTitles404Response{}, err + } params := sqlc.SearchUserTitlesParams{ + UserID: userID, Word: word, TitleStatuses: title_statuses, UsertitleStatuses: watch_status, @@ -269,3 +312,97 @@ func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersU return oapi.GetUsersUserIdTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil } + +func EmailToStringPtr(e *types.Email) *string { + if e == nil { + return nil + } + s := string(*e) + return &s +} + +func StringToEmail(e *string) *types.Email { + if e == nil { + return nil + } + s := types.Email(*e) + return &s +} + +// UpdateUser implements oapi.StrictServerInterface. +func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) { + + params := sqlc.UpdateUserParams{ + AvatarID: request.Body.AvatarId, + DispName: request.Body.DispName, + UserDesc: request.Body.UserDesc, + Mail: EmailToStringPtr(request.Body.Mail), + UserID: request.UserId, + } + + user, err := s.db.UpdateUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUser500Response{}, nil + } + + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // AvatarId: user.AvatarID, + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + + return oapi.UpdateUser200JSONResponse(oapi_user), nil +} + +func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { + + status, err := UserTitleStatus2Sqlc1(&request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle400Response{}, nil + } + + params := sqlc.InsertUserTitleParams{ + UserID: request.UserId, + TitleID: request.Body.Title.Id, + Status: *status, + Rate: request.Body.Rate, + ReviewID: request.Body.ReviewId, + } + + user_title, err := s.db.InsertUserTitle(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + oapi_usertitle := struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status oapi.UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` + }{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.AddUserTitle200JSONResponse{Data: &oapi_usertitle}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index d064660..0146b25 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -9,9 +9,19 @@ VALUES ($1, $2) RETURNING id, storage_type, image_path; -- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one @@ -58,15 +68,15 @@ RETURNING id, tag_names; -- VALUES ($1, $2, $3, $4, $5, $6, $7) -- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; --- -- name: UpdateUser :one --- UPDATE users --- SET --- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), --- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), --- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), --- passhash = COALESCE(sqlc.narg('passhash'), passhash) --- WHERE user_id = sqlc.arg('user_id') --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + mail = COALESCE(sqlc.narg('mail'), mail) +WHERE id = sqlc.arg('user_id') +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- -- name: DeleteUser :exec -- DELETE FROM users @@ -76,7 +86,7 @@ RETURNING id, tag_names; -- sqlc.struct: TitlesFull SELECT t.*, - i.storage_type::text as title_storage_type, + 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), @@ -85,7 +95,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -112,7 +122,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + 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), @@ -193,10 +203,10 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) @@ -243,7 +253,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + 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), @@ -259,6 +269,8 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = sqlc.arg('user_id')::bigint + AND CASE WHEN sqlc.arg('forward')::boolean THEN -- forward: greater than cursor (next page) @@ -325,16 +337,16 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 + OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) ) AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) @@ -342,7 +354,7 @@ WHERE AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY CASE WHEN sqlc.arg('forward')::boolean THEN @@ -412,7 +424,7 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- DELETE FROM reviews -- WHERE review_id = $1; --- name: ListReviewsByTitle :many +-- -- name: ListReviewsByTitle :many -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date -- FROM reviews -- WHERE title_id = $1 @@ -438,10 +450,16 @@ WHERE review_id = sqlc.arg('review_id')::bigint; -- ORDER BY usertitle_id -- LIMIT $2 OFFSET $3; --- -- name: CreateUserTitle :one --- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; +-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + sqlc.arg('user_id')::bigint, + sqlc.arg('title_id')::bigint, + sqlc.arg('status')::usertitle_status_t, + sqlc.narg('rate')::int, + sqlc.narg('review_id')::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime; -- -- name: UpdateUserTitle :one -- UPDATE usertitles diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index a538968..c3a851f 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -19,6 +19,15 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://nyanimedb-auth:8082/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 909ad6c..5a25313 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { @@ -10,6 +11,8 @@ const App: React.FC = () => {
+ } /> {/* <-- маршрут для логина */} + } /> {/* <-- можно использовать тот же компонент для регистрации */} } /> } /> diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 185e5c3..6ce873e 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: '/api/v1', + BASE: 'http://10.1.0.65:8081/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/core/ApiError.ts b/modules/frontend/src/auth/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/modules/frontend/src/auth/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/modules/frontend/src/auth/core/ApiRequestOptions.ts b/modules/frontend/src/auth/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/modules/frontend/src/auth/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/modules/frontend/src/auth/core/ApiResult.ts b/modules/frontend/src/auth/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/modules/frontend/src/auth/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/modules/frontend/src/auth/core/CancelablePromise.ts b/modules/frontend/src/auth/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/modules/frontend/src/auth/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts new file mode 100644 index 0000000..2d0edf8 --- /dev/null +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (options: ApiRequestOptions) => Promise; +type Headers = Record; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + HEADERS?: Headers | Resolver | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: '/auth', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/modules/frontend/src/auth/core/request.ts b/modules/frontend/src/auth/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/modules/frontend/src/auth/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/modules/frontend/src/auth/index.ts b/modules/frontend/src/auth/index.ts new file mode 100644 index 0000000..b0989c4 --- /dev/null +++ b/modules/frontend/src/auth/index.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export { AuthService } from './services/AuthService'; diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts new file mode 100644 index 0000000..bab9c77 --- /dev/null +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -0,0 +1,58 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AuthService { + /** + * Sign up a new user + * @param requestBody + * @returns any Sign-up result + * @throws ApiError + */ + public static postAuthSignUp( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-up', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Sign in a user and return JWT + * @param requestBody + * @returns any Sign-in result with JWT + * @throws ApiError + */ + public static postAuthSignIn( + requestBody: { + nickname: string; + pass: string; + }, + ): CancelablePromise<{ + success?: boolean; + error?: string | null; + user_id?: string | null; + }> { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/sign-in', + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Access denied due to invalid credentials`, + }, + }); + } +} diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..dcd6965 --- /dev/null +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,116 @@ +import React, { useState } from "react"; +import { AuthService } from "../../auth/services/AuthService"; +import { useNavigate } from "react-router-dom"; + +export const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); // true = login, false = signup + const [nickname, setNickname] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isLogin) { + const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + if (res.success) { + // TODO: сохранить JWT в localStorage/cookie + console.log("Logged in user id:", res.user_id); + navigate("/"); // редирект после успешного входа + } else { + setError(res.error || "Login failed"); + } + } else { + const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + if (res.success) { + console.log("User signed up with id:", res.user_id); + setIsLogin(true); // переключаемся на login после регистрации + } else { + setError(res.error || "Sign up failed"); + } + } + } catch (err: any) { + console.error(err); + setError(err?.message || "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

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

+ + {error &&
{error}
} + +
+
+ + setNickname(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ + +
+ +
+ {isLogin ? ( + <> + Don't have an account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +
+
+
+ ); +}; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index e6ed628..f8781de 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,6 +1,3 @@ --- TODO: --- maybe jsonb constraints --- clean unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); @@ -24,37 +21,24 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); -CREATE TABLE reviews ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - data text NOT NULL, - rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id), - title_id bigint REFERENCES titles (id), - created_at timestamptz DEFAULT NOW() -); - -CREATE TABLE review_images ( - PRIMARY KEY (review_id, image_id), - review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, - image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE -); - CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id bigint REFERENCES images (id), + avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, passhash text NOT NULL, mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, - creation_date timestamptz NOT NULL, + creation_date timestamptz NOT NULL DEFAULT NOW(), last_login timestamptz ); + + CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, - illust_id bigint REFERENCES images (id), + illust_id bigint REFERENCES images (id) ON DELETE SET NULL, studio_desc text ); @@ -64,7 +48,7 @@ CREATE TABLE titles ( -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), - poster_id bigint REFERENCES images (id), + poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, rating float CHECK (rating >= 0 AND rating <= 10), rating_count int CHECK (rating_count >= 0), @@ -80,21 +64,36 @@ CREATE TABLE titles ( AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), - user_id bigint NOT NULL REFERENCES users (id), - title_id bigint NOT NULL REFERENCES titles (id), + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint REFERENCES reviews (id), - ctime timestamptz + review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, + ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE signals ( @@ -105,17 +104,17 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); -CREATE TABLE external_services ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL -); - -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ @@ -169,4 +168,17 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); + +CREATE OR REPLACE FUNCTION set_ctime() +RETURNS TRIGGER AS $$ +BEGIN + NEW.ctime = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_ctime_on_update +AFTER UPDATE ON usertitles +FOR EACH ROW +EXECUTE FUNCTION set_ctime(); \ No newline at end of file diff --git a/sql/models.go b/sql/models.go index 36d4c07..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -277,10 +277,10 @@ type User struct { } type Usertitle 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 pgtype.Timestamptz `json:"ctime"` + 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"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index daa2b56..a46da86 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,8 +9,6 @@ import ( "context" "encoding/json" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -114,12 +112,9 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er const getTitleByID = `-- name: GetTitleByID :one - - - SELECT t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, - i.storage_type::text as title_storage_type, + 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), @@ -128,7 +123,7 @@ SELECT s.studio_name as studio_name, s.illust_id as studio_illust_id, s.studio_desc as studio_desc, - si.storage_type::text as studio_storage_type, + si.storage_type as studio_storage_type, si.image_path as studio_image_path FROM titles as t @@ -157,36 +152,16 @@ type GetTitleByIDRow struct { EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` EpisodesLen []byte `json:"episodes_len"` - TitleStorageType string `json:"title_storage_type"` + 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 string `json:"studio_storage_type"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` StudioImagePath *string `json:"studio_image_path"` } -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; // -- name: DeleteUser :exec // DELETE FROM users // WHERE user_id = $1; @@ -249,19 +224,31 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe } const getUserByID = `-- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1 +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = $1::bigint ` type GetUserByIDRow struct { - ID int64 `json:"id"` - AvatarID *int64 `json:"avatar_id"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate time.Time `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` } func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { @@ -275,6 +262,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er &i.DispName, &i.UserDesc, &i.CreationDate, + &i.StorageType, + &i.ImagePath, ) return i, err } @@ -340,6 +329,93 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams return i, err } +const insertUserTitle = `-- name: InsertUserTitle :one + + + + + + + +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + $1::bigint, + $2::bigint, + $3::usertitle_status_t, + $4::int, + $5::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type InsertUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` +} + +// -- name: CreateReview :one +// INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) +// VALUES ($1, $2, $3, $4, $5) +// RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; +// -- name: UpdateReview :one +// UPDATE reviews +// SET +// +// image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), +// review_text = COALESCE(sqlc.narg('review_text'), review_text) +// +// WHERE review_id = sqlc.arg('review_id') +// RETURNING *; +// -- name: DeleteReview :exec +// DELETE FROM reviews +// WHERE review_id = $1; +// +// -- name: ListReviewsByTitle :many +// +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE title_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: ListReviewsByUser :many +// SELECT review_id, user_id, title_id, image_ids, review_text, creation_date +// FROM reviews +// WHERE user_id = $1 +// ORDER BY creation_date DESC +// LIMIT $2 OFFSET $3; +// -- name: GetUserTitle :one +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 AND title_id = $2; +// -- name: ListUserTitles :many +// SELECT usertitle_id, user_id, title_id, status, rate, review_id +// FROM usertitles +// WHERE user_id = $1 +// ORDER BY usertitle_id +// LIMIT $2 OFFSET $3; +func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, insertUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many SELECT t.id as id, @@ -353,7 +429,7 @@ SELECT t.season as season, t.episodes_aired as episodes_aired, t.episodes_all as episodes_all, - i.storage_type::text as title_storage_type, + 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), @@ -434,14 +510,14 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) ) - AND ($7::float IS NULL OR t.rating >= $7::float) - AND ($8::int IS NULL OR t.release_year = $8::int) - AND ($9::release_season_t IS NULL OR t.release_season = $9::release_season_t) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) GROUP BY t.id, i.id, s.id @@ -464,7 +540,7 @@ ORDER BY CASE WHEN $2::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($10::int, 100) +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { @@ -474,6 +550,7 @@ type SearchTitlesParams struct { CursorID *int64 `json:"cursor_id"` CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` @@ -492,7 +569,7 @@ type SearchTitlesRow struct { Season *int32 `json:"season"` EpisodesAired *int32 `json:"episodes_aired"` EpisodesAll *int32 `json:"episodes_all"` - TitleStorageType string `json:"title_storage_type"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` TitleImagePath *string `json:"title_image_path"` TagNames json.RawMessage `json:"tag_names"` StudioName *string `json:"studio_name"` @@ -506,6 +583,7 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, @@ -564,7 +642,7 @@ SELECT u.rate as user_rate, u.review_id as review_id, u.ctime as user_ctime, - i.storage_type::text as title_storage_type, + 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), @@ -580,43 +658,45 @@ LEFT JOIN tags as g ON (tt.tag_id = g.id) LEFT JOIN studios as s ON (t.studio_id = s.id) WHERE + u.user_id = $1::bigint + AND CASE - WHEN $1::boolean THEN + WHEN $2::boolean THEN -- forward: greater than cursor (next page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year > $3::int) OR - (t.release_year = $3::int AND t.id > $4::bigint) + ($4::int IS NULL) OR + (t.release_year > $4::int) OR + (t.release_year = $4::int AND t.id > $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating > $5::float) OR - (t.rating = $5::float AND t.id > $4::bigint) + ($6::float IS NULL) OR + (t.rating > $6::float) OR + (t.rating = $6::float AND t.id > $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id > $4::bigint) + ($5::bigint IS NULL) OR + (t.id > $5::bigint) ELSE true -- fallback END ELSE -- backward: less than cursor (prev page) - CASE $2::text + CASE $3::text WHEN 'year' THEN - ($3::int IS NULL) OR - (t.release_year < $3::int) OR - (t.release_year = $3::int AND t.id < $4::bigint) + ($4::int IS NULL) OR + (t.release_year < $4::int) OR + (t.release_year = $4::int AND t.id < $5::bigint) WHEN 'rating' THEN - ($5::float IS NULL) OR - (t.rating < $5::float) OR - (t.rating = $5::float AND t.id < $4::bigint) + ($6::float IS NULL) OR + (t.rating < $6::float) OR + (t.rating = $6::float AND t.id < $5::bigint) WHEN 'id' THEN - ($4::bigint IS NULL) OR - (t.id < $4::bigint) + ($5::bigint IS NULL) OR + (t.id < $5::bigint) ELSE true END @@ -624,7 +704,7 @@ WHERE AND ( CASE - WHEN $6::text IS NOT NULL THEN + WHEN $7::text IS NOT NULL THEN ( SELECT bool_and( EXISTS ( @@ -636,7 +716,7 @@ WHERE FROM unnest( ARRAY( SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($6::text, ' ')) AS w + FROM unnest(string_to_array($7::text, ' ')) AS w WHERE trim(w) <> '' ) ) AS pattern @@ -646,94 +726,100 @@ WHERE ) AND ( - 'title_statuses'::title_status_t[] IS NULL - OR array_length('title_statuses'::title_status_t[], 1) IS NULL - OR array_length('title_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('title_statuses'::title_status_t[]) + $8::title_status_t[] IS NULL + OR array_length($8::title_status_t[], 1) IS NULL + OR array_length($8::title_status_t[], 1) = 0 + OR t.title_status = ANY($8::title_status_t[]) ) AND ( - 'usertitle_statuses'::title_status_t[] IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) IS NULL - OR array_length('usertitle_statuses'::title_status_t[], 1) = 0 - OR t.title_status = ANY('usertitle_statuses'::title_status_t[]) + $9::usertitle_status_t[] IS NULL + OR array_length($9::usertitle_status_t[], 1) IS NULL + OR array_length($9::usertitle_status_t[], 1) = 0 + OR u.status = ANY($9::usertitle_status_t[]) ) - AND ($7::int IS NULL OR u.rate >= $7::int) - AND ($8::float IS NULL OR t.rating >= $8::float) - AND ($9::int IS NULL OR t.release_year = $9::int) - AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + AND ($10::int IS NULL OR u.rate >= $10::int) + AND ($11::float IS NULL OR t.rating >= $11::float) + AND ($12::int IS NULL OR t.release_year = $12::int) + AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) GROUP BY - t.id, i.id, s.id + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id ORDER BY - CASE WHEN $1::boolean THEN + CASE WHEN $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END ASC, - CASE WHEN NOT $1::boolean THEN + CASE WHEN NOT $2::boolean THEN CASE - WHEN $2::text = 'id' THEN t.id - WHEN $2::text = 'year' THEN t.release_year - WHEN $2::text = 'rating' THEN t.rating - WHEN $2::text = 'rate' THEN u.rate + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate END END DESC, - CASE WHEN $2::text <> 'id' THEN t.id END ASC + CASE WHEN $3::text <> 'id' THEN t.id END ASC -LIMIT COALESCE($11::int, 100) +LIMIT COALESCE($14::int, 100) ` type SearchUserTitlesParams struct { - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` - CursorYear *int32 `json:"cursor_year"` - CursorID *int64 `json:"cursor_id"` - CursorRating *float64 `json:"cursor_rating"` - Word *string `json:"word"` - Rate *int32 `json:"rate"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Limit *int32 `json:"limit"` + UserID int64 `json:"user_id"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - ID int64 `json:"id"` - TitleNames json.RawMessage `json:"title_names"` - 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"` - UserID int64 `json:"user_id"` - UsertitleStatus UsertitleStatusT `json:"usertitle_status"` - UserRate *int32 `json:"user_rate"` - ReviewID *int64 `json:"review_id"` - UserCtime pgtype.Timestamptz `json:"user_ctime"` - TitleStorageType string `json:"title_storage_type"` - TitleImagePath *string `json:"title_image_path"` - TagNames json.RawMessage `json:"tag_names"` - StudioName *string `json:"studio_name"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + 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"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime time.Time `json:"user_ctime"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.UserID, arg.Forward, arg.SortBy, arg.CursorYear, arg.CursorID, arg.CursorRating, arg.Word, + arg.TitleStatuses, + arg.UsertitleStatuses, arg.Rate, arg.Rating, arg.ReleaseYear, @@ -778,3 +864,64 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } return items, nil } + +const updateUser = `-- name: UpdateUser :one + + +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc), + mail = COALESCE($4, mail) +WHERE id = $5 +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail +` + +type UpdateUserParams struct { + AvatarID *int64 `json:"avatar_id"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + Mail *string `json:"mail"` + UserID int64 `json:"user_id"` +} + +type UpdateUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + Mail *string `json:"mail"` +} + +// -- name: ListUsers :many +// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +// FROM users +// ORDER BY user_id +// LIMIT $1 OFFSET $2; +// -- name: CreateUser :one +// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) +// VALUES ($1, $2, $3, $4, $5, $6, $7) +// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.Mail, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.Mail, + ) + return i, err +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 3338c35..de67bcf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -14,6 +14,11 @@ sql: emit_pointers_for_null_types: true emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "storage_type_t" + nullable: true + go_type: + type: "StorageTypeT" + pointer: true - db_type: "jsonb" go_type: "encoding/json.RawMessage" - db_type: "uuid"