From 169bb482ce522ddac00ae5c841f6f6c618d38f51 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 19:42:25 +0300 Subject: [PATCH 01/17] feat: desc field for title was added --- sql/migrations/000001_init.up.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..d6353d6 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -47,6 +47,8 @@ CREATE TABLE titles ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, + -- example {"ru": "Кулинарное аниме как правильно приготовить людей.","en": "A culinary anime about how to cook people properly."} + title_desc jsonb, studio_id bigint NOT NULL REFERENCES studios (id), poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, From 40e341c05ad2f5fea246c82afa40717450d2ebf6 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 20:13:16 +0300 Subject: [PATCH 02/17] feat: query SearchUsers was written --- sql/models.go | 1 + sql/queries.sql.go | 85 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/sql/models.go b/sql/models.go index 842d58c..b1ea282 100644 --- a/sql/models.go +++ b/sql/models.go @@ -246,6 +246,7 @@ type Tag struct { type Title struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..0c17599 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -129,7 +129,7 @@ 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, + t.id, t.title_names, t.title_desc, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, i.storage_type as title_storage_type, i.image_path as title_image_path, COALESCE( @@ -157,6 +157,7 @@ GROUP BY type GetTitleByIDRow struct { ID int64 `json:"id"` TitleNames json.RawMessage `json:"title_names"` + TitleDesc []byte `json:"title_desc"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -185,6 +186,7 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByID err := row.Scan( &i.ID, &i.TitleNames, + &i.TitleDesc, &i.StudioID, &i.PosterID, &i.TitleStatus, @@ -638,6 +640,87 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]S return items, nil } +const searchUser = `-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + $1::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim($1::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + $2::int IS NULL + OR u.id > $2::int + ) +ORDER BY u.id ASC +LIMIT COALESCE($3::int, 20) +` + +type SearchUserParams struct { + Word *string `json:"word"` + Cursor *int32 `json:"cursor"` + Limit *int32 `json:"limit"` +} + +type SearchUserRow 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"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` +} + +func (q *Queries) SearchUser(ctx context.Context, arg SearchUserParams) ([]SearchUserRow, error) { + rows, err := q.db.Query(ctx, searchUser, arg.Word, arg.Cursor, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchUserRow{} + for rows.Next() { + var i SearchUserRow + if err := rows.Scan( + &i.ID, + &i.AvatarID, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.StorageType, + &i.ImagePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchUserTitles = `-- name: SearchUserTitles :many SELECT From fe18c0d865c4d27e27ad7be4d79ce328693c6850 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 20:14:08 +0300 Subject: [PATCH 03/17] feat /users path is specified --- api/_build/openapi.yaml | 47 ++++++++++++++ api/api.gen.go | 131 ++++++++++++++++++++++++++++++++++++++++ api/openapi.yaml | 2 + api/paths/users.yaml | 46 ++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 api/paths/users.yaml diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e096beb..7f483fa 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -122,6 +122,53 @@ paths: description: Unknown server error security: - JwtAuthCookies: [] + /users/: + get: + summary: 'Search user by nickname or dispname (both in one param), response is always sorted by id' + parameters: + - name: word + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int32 + default: 10 + - name: cursor_id + in: query + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + description: List of users + type: array + items: + $ref: '#/components/schemas/User' + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error '/users/{user_id}': get: operationId: getUsersId diff --git a/api/api.gen.go b/api/api.gen.go index 459a3e4..4fa16f4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -201,6 +201,15 @@ type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + Word *string `form:"word,omitempty" json:"word,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + + // CursorId pass cursor naked + CursorId *int32 `form:"cursor_id,omitempty" json:"cursor_id,omitempty"` +} + // GetUsersIdParams defines parameters for GetUsersId. type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` @@ -276,6 +285,9 @@ type ServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(c *gin.Context, titleId int64, params GetTitleParams) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(c *gin.Context, params GetUsersParams) // Get user info // (GET /users/{user_id}) GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) @@ -459,6 +471,48 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { siw.Handler.GetTitle(c, titleId, params) } +// GetUsers operation middleware +func (siw *ServerInterfaceWrapper) GetUsers(c *gin.Context) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUsersParams + + // ------------- Optional query parameter "word" ------------- + + err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter word: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "cursor_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "cursor_id", c.Request.URL.Query(), ¶ms.CursorId) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter cursor_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUsers(c, params) +} + // GetUsersId operation middleware func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { @@ -799,6 +853,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/", wrapper.GetUsers) router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) @@ -904,6 +959,52 @@ func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) return nil } +type GetUsersRequestObject struct { + Params GetUsersParams +} + +type GetUsersResponseObject interface { + VisitGetUsersResponse(w http.ResponseWriter) error +} + +type GetUsers200JSONResponse struct { + Cursor int64 `json:"cursor"` + + // Data List of users + Data []User `json:"data"` +} + +func (response GetUsers200JSONResponse) VisitGetUsersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUsers204Response struct { +} + +func (response GetUsers204Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUsers400Response struct { +} + +func (response GetUsers400Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUsers500Response struct { +} + +func (response GetUsers500Response) VisitGetUsersResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetUsersIdRequestObject struct { UserId string `json:"user_id"` Params GetUsersIdParams @@ -1305,6 +1406,9 @@ type StrictServerInterface interface { // Get title description // (GET /titles/{title_id}) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Search user by nickname or dispname (both in one param), response is always sorted by id + // (GET /users/) + GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error) // Get user info // (GET /users/{user_id}) GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) @@ -1395,6 +1499,33 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTit } } +// GetUsers operation middleware +func (sh *strictHandler) GetUsers(ctx *gin.Context, params GetUsersParams) { + var request GetUsersRequestObject + + request.Params = params + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUsers(ctx, request.(GetUsersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUsers") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUsersResponseObject); ok { + if err := validResponse.VisitGetUsersResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUsersId operation middleware func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { var request GetUsersIdRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index d84797f..0759a54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -11,6 +11,8 @@ paths: $ref: "./paths/titles.yaml" /titles/{title_id}: $ref: "./paths/titles-id.yaml" + /users/: + $ref: "./paths/users.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" /users/{user_id}/titles: diff --git a/api/paths/users.yaml b/api/paths/users.yaml new file mode 100644 index 0000000..14fb0c0 --- /dev/null +++ b/api/paths/users.yaml @@ -0,0 +1,46 @@ +get: + summary: Search user by nickname or dispname (both in one param), response is always sorted by id + parameters: + - in: query + name: word + schema: + type: string + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: cursor_id + description: pass cursor naked + schema: + type: integer + format: int32 + default: 1 + responses: + '200': + description: List of users with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '../schemas/User.yaml' + description: List of users + cursor: + type: integer + format: int64 + default: 1 + required: + - data + - cursor + '204': + description: No users found + '400': + description: Request params are not correct + '500': + description: Unknown server error From 6a5994e33e5f840d7e3b646d7ed7266f1a4d9cc9 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 20:15:05 +0300 Subject: [PATCH 04/17] feat: handler for get /users is implemented --- modules/backend/handlers/users.go | 36 +++++++++++++++++++++++++++++++ modules/backend/queries.sql | 31 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index d6faade..995d5af 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -485,3 +485,39 @@ func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleReque return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil } + +// GetUsers implements oapi.StrictServerInterface. +func (s *Server) GetUsers(ctx context.Context, request oapi.GetUsersRequestObject) (oapi.GetUsersResponseObject, error) { + params := sqlc.SearchUserParams{ + Word: request.Params.Word, + Cursor: request.Params.CursorId, + Limit: request.Params.Limit, + } + _users, err := s.db.SearchUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsers500Response{}, nil + } + if len(_users) == 0 { + return oapi.GetUsers204Response{}, nil + } + + var users []oapi.User + var cursor int64 + for _, user := range _users { + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // add image + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + users = append(users, oapi_user) + + cursor = user.ID + } + + return oapi.GetUsers200JSONResponse{Data: users, Cursor: cursor}, nil +} diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index ff41cb1..03502c4 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -23,6 +23,37 @@ FROM users as t LEFT JOIN images as i ON (t.avatar_id = i.id) WHERE t.id = sqlc.arg('id')::bigint; +-- name: SearchUser :many +SELECT + u.id AS id, + u.avatar_id AS avatar_id, + u.mail AS mail, + u.nickname AS nickname, + u.disp_name AS disp_name, + u.user_desc AS user_desc, + u.creation_date AS creation_date, + i.storage_type AS storage_type, + i.image_path AS image_path +FROM users AS u +LEFT JOIN images AS i ON u.avatar_id = i.id +WHERE + ( + sqlc.narg('word')::text IS NULL + OR ( + SELECT bool_and( + u.nickname ILIKE ('%' || term || '%') + OR u.disp_name ILIKE ('%' || term || '%') + ) + FROM unnest(string_to_array(trim(sqlc.narg('word')::text), ' ')) AS term + WHERE term <> '' + ) + ) + AND ( + sqlc.narg('cursor')::int IS NULL + OR u.id > sqlc.narg('cursor')::int + ) +ORDER BY u.id ASC +LIMIT COALESCE(sqlc.narg('limit')::int, 20); -- name: GetStudioByID :one SELECT * From 62e0633e69a5bd4b658847155bb808beb34b821b Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 21:20:51 +0300 Subject: [PATCH 05/17] fix: rmq --- modules/backend/handlers/common.go | 22 +-- modules/backend/handlers/titles.go | 1 - modules/backend/main.go | 3 +- modules/backend/rmq/rabbit.go | 214 ++++++----------------------- 4 files changed, 53 insertions(+), 187 deletions(-) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index cad4f0f..58862e1 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -9,24 +9,24 @@ import ( "strconv" ) -type Handler struct { - publisher *rmq.Publisher -} +// type Handler struct { +// publisher *rmq.Publisher +// } -func New(publisher *rmq.Publisher) *Handler { - return &Handler{publisher: publisher} -} +// func New(publisher *rmq.Publisher) *Handler { +// return &Handler{publisher: publisher} +// } type Server struct { - db *sqlc.Queries - publisher *rmq.Publisher + db *sqlc.Queries + // publisher *rmq.Publisher RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { +func NewServer(db *sqlc.Queries, rpcclient *rmq.RPCClient) *Server { return &Server{ - db: db, - publisher: publisher, + db: db, + // publisher: publisher, RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 300cc87..7aeeb11 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -197,7 +197,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje // Делаем RPC-вызов — и ЖДЁМ ответа err := s.RPCclient.Call( ctx, - "svc.media.process.requests", // ← очередь микросервиса mqreq, &reply, ) diff --git a/modules/backend/main.go b/modules/backend/main.go index 755e3ef..e7e6ec8 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -59,10 +59,9 @@ func main() { } defer rmqConn.Close() - publisher := rmq.NewPublisher(rmqConn) rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, publisher, rpcClient) + server := handlers.NewServer(queries, rpcClient) r.Use(cors.New(cors.Config{ AllowOrigins: []string{AppConfig.ServiceAddress}, diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go index 52c1979..25abbdb 100644 --- a/modules/backend/rmq/rabbit.go +++ b/modules/backend/rmq/rabbit.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "fmt" - oapi "nyanimedb/api" - "sync" "time" + oapi "nyanimedb/api" + amqp "github.com/rabbitmq/amqp091-go" ) +const RPCQueueName = "anime_import_rpc" + +// RabbitRequest не меняем type RabbitRequest struct { Name string `json:"name"` Statuses []oapi.TitleStatus `json:"statuses,omitempty"` @@ -20,151 +23,6 @@ type RabbitRequest struct { Timestamp time.Time `json:"timestamp"` } -// Publisher — потокобезопасный публикатор с пулом каналов. -type Publisher struct { - conn *amqp.Connection - pool *sync.Pool -} - -// NewPublisher создаёт новый Publisher. -// conn должен быть уже установленным и healthy. -// Рекомендуется передавать durable connection с reconnect-логикой. -func NewPublisher(conn *amqp.Connection) *Publisher { - return &Publisher{ - conn: conn, - pool: &sync.Pool{ - New: func() any { - ch, err := conn.Channel() - if err != nil { - // Паника уместна: невозможность открыть канал — критическая ошибка инициализации - panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) - } - return ch - }, - }, - } -} - -// Publish публикует сообщение в указанную очередь. -// Очередь объявляется как durable (если не существует). -// Поддерживает context для отмены/таймаута. -func (p *Publisher) Publish( - ctx context.Context, - queueName string, - payload RabbitRequest, - opts ...PublishOption, -) error { - // Применяем опции - options := &publishOptions{ - contentType: "application/json", - deliveryMode: amqp.Persistent, - timestamp: time.Now(), - } - for _, opt := range opts { - opt(options) - } - - // Сериализуем payload - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) - } - - // Берём канал из пула - ch := p.getChannel() - if ch == nil { - return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") - } - defer p.returnChannel(ch) - - // Объявляем очередь (idempotent) - q, err := ch.QueueDeclare( - queueName, - true, // durable - false, // auto-delete - false, // exclusive - false, // no-wait - nil, // args - ) - if err != nil { - return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) - } - - // Подготавливаем сообщение - msg := amqp.Publishing{ - DeliveryMode: options.deliveryMode, - ContentType: options.contentType, - Timestamp: options.timestamp, - Body: body, - } - - // Публикуем с учётом контекста - done := make(chan error, 1) - go func() { - err := ch.Publish( - "", // exchange (default) - q.Name, // routing key - false, // mandatory - false, // immediate - msg, - ) - done <- err - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (p *Publisher) getChannel() *amqp.Channel { - raw := p.pool.Get() - if raw == nil { - ch, _ := p.conn.Channel() - return ch - } - ch := raw.(*amqp.Channel) - if ch.IsClosed() { // ← теперь есть! - ch.Close() // освободить ресурсы - ch, _ = p.conn.Channel() - } - return ch -} - -// returnChannel возвращает канал в пул, если он жив. -func (p *Publisher) returnChannel(ch *amqp.Channel) { - if ch != nil && !ch.IsClosed() { - p.pool.Put(ch) - } -} - -// PublishOption позволяет кастомизировать публикацию. -type PublishOption func(*publishOptions) - -type publishOptions struct { - contentType string - deliveryMode uint8 - timestamp time.Time -} - -// WithContentType устанавливает Content-Type (по умолчанию "application/json"). -func WithContentType(ct string) PublishOption { - return func(o *publishOptions) { o.contentType = ct } -} - -// WithTransient делает сообщение transient (не сохраняется на диск). -// По умолчанию — Persistent. -func WithTransient() PublishOption { - return func(o *publishOptions) { o.deliveryMode = amqp.Transient } -} - -// WithTimestamp устанавливает кастомную метку времени. -func WithTimestamp(ts time.Time) PublishOption { - return func(o *publishOptions) { o.timestamp = ts } -} - type RPCClient struct { conn *amqp.Connection timeout time.Duration @@ -174,37 +32,48 @@ func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { return &RPCClient{conn: conn, timeout: timeout} } -// Call отправляет запрос в очередь и ждёт ответа. -// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). func (c *RPCClient) Call( ctx context.Context, - requestQueue string, request RabbitRequest, replyPayload any, ) error { - // 1. Создаём временный канал (не из пула!) + + // 1. Канал для запроса и ответа ch, err := c.conn.Channel() if err != nil { return fmt.Errorf("channel: %w", err) } defer ch.Close() - // 2. Создаём временную очередь для ответов - q, err := ch.QueueDeclare( - "", // auto name - false, // not durable - true, // exclusive - true, // auto-delete + // 2. Декларируем фиксированную очередь RPC (идемпотентно) + _, err = ch.QueueDeclare( + RPCQueueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, + ) + if err != nil { + return fmt.Errorf("declare rpc queue: %w", err) + } + + // 3. Создаём временную очередь ДЛЯ ОТВЕТА + replyQueue, err := ch.QueueDeclare( + "", + false, + true, + true, false, nil, ) if err != nil { - return fmt.Errorf("reply queue: %w", err) + return fmt.Errorf("declare reply queue: %w", err) } - // 3. Подписываемся на ответы + // 4. Подписываемся на очередь ответов msgs, err := ch.Consume( - q.Name, + replyQueue.Name, "", true, // auto-ack true, // exclusive @@ -213,28 +82,28 @@ func (c *RPCClient) Call( nil, ) if err != nil { - return fmt.Errorf("consume: %w", err) + return fmt.Errorf("consume reply: %w", err) } - // 4. Готовим correlation ID - corrID := time.Now().UnixNano() + // correlation ID + corrID := fmt.Sprintf("%d", time.Now().UnixNano()) - // 5. Сериализуем запрос + // 5. сериализация запроса body, err := json.Marshal(request) if err != nil { return fmt.Errorf("marshal request: %w", err) } - // 6. Публикуем запрос + // 6. Публикация RPC-запроса err = ch.Publish( "", - requestQueue, + RPCQueueName, // ← фиксированная очередь! false, false, amqp.Publishing{ ContentType: "application/json", - CorrelationId: fmt.Sprintf("%d", corrID), - ReplyTo: q.Name, + CorrelationId: corrID, + ReplyTo: replyQueue.Name, Timestamp: time.Now(), Body: body, }, @@ -244,18 +113,17 @@ func (c *RPCClient) Call( } // 7. Ждём ответ с таймаутом - ctx, cancel := context.WithTimeout(ctx, c.timeout) + timeoutCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() for { select { case msg := <-msgs: - if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + if msg.CorrelationId == corrID { return json.Unmarshal(msg.Body, replyPayload) } - // игнорируем другие сообщения (маловероятно, но возможно) - case <-ctx.Done(): - return ctx.Err() // timeout or cancelled + case <-timeoutCtx.Done(): + return fmt.Errorf("rpc timeout: %w", timeoutCtx.Err()) } } } From 57956f1f6e0b8cd565584317fc14d76f6dc8c5de Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:36:05 +0300 Subject: [PATCH 06/17] feat: field description is added to Title --- api/_build/openapi.yaml | 5 +++++ api/api.gen.go | 3 +++ api/schemas/Title.yaml | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 7f483fa..5b6f731 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -647,6 +647,11 @@ components: example: - Attack on Titan - AoT + title_desc: + description: 'Localized description. Key = language (ISO 639-1), value = description.' + type: object + additionalProperties: + type: string studio: $ref: '#/components/schemas/Studio' tags: diff --git a/api/api.gen.go b/api/api.gen.go index 4fa16f4..ff37ed9 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -113,6 +113,9 @@ type Title struct { // Tags Array of localized tags Tags Tags `json:"tags"` + // TitleDesc Localized description. Key = language (ISO 639-1), value = description. + TitleDesc *map[string]string `json:"title_desc,omitempty"` + // TitleNames Localized titles. Key = language (ISO 639-1), value = list of names TitleNames map[string][]string `json:"title_names"` diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 877ee24..fac4a3f 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -30,6 +30,11 @@ properties: - Титаны ja: - 進撃の巨人 + title_desc: + type: object + description: Localized description. Key = language (ISO 639-1), value = description. + additionalProperties: + type: string studio: $ref: ./Studio.yaml tags: From 6d14ac365bbbb9d795a81b3c2028ab4fbd2c0eb4 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:37:13 +0300 Subject: [PATCH 07/17] feat: title desc handling --- modules/backend/handlers/common.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 58862e1..7f2807f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -73,6 +73,14 @@ func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { } oapi_title.TitleNames = title_names + if len(title.TitleDesc) > 0 { + title_descs := make(map[string]string, 0) + err = json.Unmarshal(title.TitleDesc, &title_descs) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleDesc: %v", err) + } + oapi_title.TitleDesc = &title_descs + } if len(title.EpisodesLen) > 0 { episodes_lens := make(map[string]float64, 0) err = json.Unmarshal(title.EpisodesLen, &episodes_lens) From 9cb3f94e2700cfeee798f74b3b8eda672cee1fea Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Fri, 5 Dec 2025 23:53:29 +0300 Subject: [PATCH 08/17] feat: gif fot waiting --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 481d116..449cb70 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -127,7 +127,16 @@ const handleLoadMore = async () => { - {loading &&
Loading...
} + {loading && ( +
+ Loading... + Loading animation +
+ )} {!loading && titles.length === 0 && (
No titles found.
From 90d7de51f3f81649fb5b713542d011db5f150968 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 00:04:51 +0300 Subject: [PATCH 09/17] fix: gif scaled --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 449cb70..75d8fa5 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -133,7 +133,7 @@ const handleLoadMore = async () => { Loading animation )} From 7623adf2a7a75d9100bc2d1d54f0f0c1ea5eedfd Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 00:27:59 +0300 Subject: [PATCH 10/17] fix --- modules/frontend/src/pages/TitlesPage/TitlesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 75d8fa5..727e072 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -131,9 +131,9 @@ const handleLoadMore = async () => {
Loading... Loading animation
)} From e67f0d7e5a89924d232098b75f51ba9c1c11477e Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 04:03:04 +0300 Subject: [PATCH 11/17] feat: get impersonation token implementation --- auth/auth.gen.go | 115 +++++++++++++++++++++++++-- auth/openapi-auth.yaml | 124 +++++++++++------------------- modules/auth/handlers/handlers.go | 38 ++++++++- modules/auth/queries.sql | 4 + sql/migrations/000001_init.up.sql | 5 +- sql/models.go | 5 +- sql/queries.sql.go | 13 ++++ 7 files changed, 209 insertions(+), 95 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index b7cd839..89a2168 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,6 +13,23 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody struct { + TgId *int64 `json:"tg_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage +} + +// GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody0 = interface{} + +// GetImpersonationTokenJSONBody1 defines parameters for GetImpersonationToken. +type GetImpersonationTokenJSONBody1 = interface{} + // PostSignInJSONBody defines parameters for PostSignIn. type PostSignInJSONBody struct { Nickname string `json:"nickname"` @@ -25,6 +42,9 @@ type PostSignUpJSONBody struct { Pass string `json:"pass"` } +// GetImpersonationTokenJSONRequestBody defines body for GetImpersonationToken for application/json ContentType. +type GetImpersonationTokenJSONRequestBody GetImpersonationTokenJSONBody + // PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. type PostSignInJSONRequestBody PostSignInJSONBody @@ -33,6 +53,9 @@ type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(c *gin.Context) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(c *gin.Context) @@ -50,6 +73,21 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// GetImpersonationToken operation middleware +func (siw *ServerInterfaceWrapper) GetImpersonationToken(c *gin.Context) { + + c.Set(BearerAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetImpersonationToken(c) +} + // PostSignIn operation middleware func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { @@ -103,10 +141,41 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/get-impersonation-token", wrapper.GetImpersonationToken) router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } +type UnauthorizedErrorResponse struct { +} + +type GetImpersonationTokenRequestObject struct { + Body *GetImpersonationTokenJSONRequestBody +} + +type GetImpersonationTokenResponseObject interface { + VisitGetImpersonationTokenResponse(w http.ResponseWriter) error +} + +type GetImpersonationToken200JSONResponse struct { + // AccessToken JWT access token + AccessToken string `json:"access_token"` +} + +func (response GetImpersonationToken200JSONResponse) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetImpersonationToken401Response = UnauthorizedErrorResponse + +func (response GetImpersonationToken401Response) VisitGetImpersonationTokenResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + type PostSignInRequestObject struct { Body *PostSignInJSONRequestBody } @@ -127,15 +196,11 @@ func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.Respons return json.NewEncoder(w).Encode(response) } -type PostSignIn401JSONResponse struct { - Error *string `json:"error,omitempty"` -} +type PostSignIn401Response = UnauthorizedErrorResponse -func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") +func (response PostSignIn401Response) VisitPostSignInResponse(w http.ResponseWriter) error { w.WriteHeader(401) - - return json.NewEncoder(w).Encode(response) + return nil } type PostSignUpRequestObject struct { @@ -159,6 +224,9 @@ func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.Respons // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get service impersontaion token + // (POST /get-impersonation-token) + GetImpersonationToken(ctx context.Context, request GetImpersonationTokenRequestObject) (GetImpersonationTokenResponseObject, error) // Sign in a user and return JWT // (POST /sign-in) PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) @@ -179,6 +247,39 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetImpersonationToken operation middleware +func (sh *strictHandler) GetImpersonationToken(ctx *gin.Context) { + var request GetImpersonationTokenRequestObject + + var body GetImpersonationTokenJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetImpersonationToken(ctx, request.(GetImpersonationTokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetImpersonationToken") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetImpersonationTokenResponseObject); ok { + if err := validResponse.VisitGetImpersonationTokenResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // PostSignIn operation middleware func (sh *strictHandler) PostSignIn(ctx *gin.Context) { var request PostSignInRequestObject diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 5f3ebd6..93db937 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -10,6 +10,7 @@ paths: /sign-up: post: summary: Sign up a new user + operationId: postSignUp tags: [Auth] requestBody: required: true @@ -41,6 +42,7 @@ paths: /sign-in: post: summary: Sign in a user and return JWT + operationId: postSignIn tags: [Auth] requestBody: required: true @@ -73,88 +75,52 @@ paths: user_name: type: string "401": - description: Access denied due to invalid credentials + $ref: '#/components/responses/UnauthorizedError' + + /get-impersonation-token: + post: + summary: Get service impersontaion token + operationId: getImpersonationToken + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + format: int64 + tg_id: + type: integer + format: int64 + oneOf: + - required: ["user_id"] + - required: ["tg_id"] + responses: + "200": + description: Generated impersonation access token content: application/json: schema: type: object + required: + - access_token properties: - error: + access_token: type: string - example: "Access denied" - # /auth/verify-token: - # post: - # summary: Verify JWT validity - # tags: [Auth] - # requestBody: - # required: true - # content: - # application/json: - # schema: - # type: object - # required: [token] - # properties: - # token: - # type: string - # description: JWT token to validate - # responses: - # "200": - # description: Token validation result - # content: - # application/json: - # schema: - # type: object - # properties: - # valid: - # type: boolean - # description: True if token is valid - # user_id: - # type: string - # nullable: true - # description: User ID extracted from token if valid - # error: - # type: string - # nullable: true - # description: Error message if token is invalid - # /auth/refresh-token: - # post: - # summary: Refresh JWT using a refresh token - # tags: [Auth] - # requestBody: - # required: true - # content: - # application/json: - # schema: - # type: object - # required: [refresh_token] - # properties: - # refresh_token: - # type: string - # description: JWT refresh token obtained from sign-in - # responses: - # "200": - # description: New access (and optionally refresh) token - # content: - # application/json: - # schema: - # type: object - # properties: - # valid: - # type: boolean - # description: True if refresh token was valid - # user_id: - # type: string - # nullable: true - # description: User ID extracted from refresh token - # access_token: - # type: string - # description: New access token - # nullable: true - # refresh_token: - # type: string - # description: New refresh token (optional) - # nullable: true - # error: - # type: string - # nullable: true - # description: Error message if refresh token is invalid + description: JWT access token + "401": + $ref: '#/components/responses/UnauthorizedError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + responses: + UnauthorizedError: + description: Access token is missing or invalid \ No newline at end of file diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..39067a6 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -47,10 +47,28 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } +func (s Server) generateImpersonationToken(userID string, impersonated_by string) (accessToken string, err error) { + accessClaims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + "imp_id": impersonated_by, + } + + at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + + accessToken, err = at.SignedString(s.JwtPrivateKey) + if err != nil { + return "", err + } + + return accessToken, nil +} + func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), + //TODO: add created_at } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = at.SignedString(s.JwtPrivateKey) @@ -119,10 +137,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject // TODO: return 500 } if !ok { - err_msg := "invalid credentials" - return auth.PostSignIn401JSONResponse{ - Error: &err_msg, - }, nil + return auth.PostSignIn401Response{}, nil } accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) @@ -144,6 +159,21 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } +func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { + ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) + if !ok { + log.Print("failed to get gin context") + // TODO: change to 500 + return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + } + + token := ginCtx.Request.Header.Get("Authorization") + log.Printf("got auth token: %s", token) + //s.db.GetExternalServiceByToken() + + return auth.PostSignIn401Response{}, nil +} + // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { // valid := false // var userID *string diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 828d2af..363f07a 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -9,3 +9,7 @@ INTO users (passhash, nickname) VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) RETURNING id; +-- name: GetExternalServiceByToken :one +SELECT * +FROM external_services +WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 3499fe2..cda8d71 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -33,8 +33,6 @@ CREATE TABLE users ( last_login timestamptz ); - - CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, @@ -106,7 +104,8 @@ CREATE TABLE signals ( CREATE TABLE external_services ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL + name text UNIQUE NOT NULL, + auth_token text ); CREATE TABLE external_ids ( diff --git a/sql/models.go b/sql/models.go index 842d58c..ee30f58 100644 --- a/sql/models.go +++ b/sql/models.go @@ -193,8 +193,9 @@ type ExternalID struct { } type ExternalService struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + AuthToken *string `json:"auth_token"` } type Image struct { diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 1cca986..7fd8765 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -74,6 +74,19 @@ func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams return i, err } +const getExternalServiceByToken = `-- name: GetExternalServiceByToken :one +SELECT id, name, auth_token +FROM external_services +WHERE auth_token = $1 +` + +func (q *Queries) GetExternalServiceByToken(ctx context.Context, authToken *string) (ExternalService, error) { + row := q.db.QueryRow(ctx, getExternalServiceByToken, authToken) + var i ExternalService + err := row.Scan(&i.ID, &i.Name, &i.AuthToken) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images From 184868b142376c800d82988ccf05950db810c9a8 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 04:13:27 +0300 Subject: [PATCH 12/17] feat: file upload imlemented --- api/_build/openapi.yaml | 36 ++++++++ api/api.gen.go | 109 ++++++++++++++++++++++ api/openapi.yaml | 4 +- api/paths/media_upload.yaml | 37 ++++++++ go.mod | 16 ++-- go.sum | 20 ++++ modules/backend/handlers/images.go | 141 +++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 8 deletions(-) create mode 100644 api/paths/media_upload.yaml create mode 100644 modules/backend/handlers/images.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5b6f731..9ed5b5f 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -527,6 +527,42 @@ paths: description: Internal server error security: - XsrfAuthHeader: [] + /media/upload: + post: + summary: 'Upload an image (PNG, JPEG, or WebP)' + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + encoding: + image: + contentType: 'image/png, image/jpeg, image/webp' + multipart/form-data: + schema: + image: + type: string + format: binary + description: 'Image file (PNG, JPEG, or WebP)' + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Image' + '400': + description: 'Bad request — e.g., invalid/malformed image, empty file' + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + '500': + description: Internal server error components: parameters: cursor: diff --git a/api/api.gen.go b/api/api.gen.go index ff37ed9..d93e925 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -7,7 +7,10 @@ import ( "context" "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" + "strings" "time" "github.com/gin-gonic/gin" @@ -181,6 +184,9 @@ type UserTitleStatus string // Cursor defines model for cursor. type Cursor = string +// PostMediaUploadMultipartBody defines parameters for PostMediaUpload. +type PostMediaUploadMultipartBody = interface{} + // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -271,6 +277,9 @@ type UpdateUserTitleJSONBody struct { Status *UserTitleStatus `json:"status,omitempty"` } +// PostMediaUploadMultipartRequestBody defines body for PostMediaUpload for multipart/form-data ContentType. +type PostMediaUploadMultipartRequestBody = PostMediaUploadMultipartBody + // UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. type UpdateUserJSONRequestBody UpdateUserJSONBody @@ -282,6 +291,9 @@ type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(c *gin.Context) // Get titles // (GET /titles) GetTitles(c *gin.Context, params GetTitlesParams) @@ -323,6 +335,19 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) +// PostMediaUpload operation middleware +func (siw *ServerInterfaceWrapper) PostMediaUpload(c *gin.Context) { + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostMediaUpload(c) +} + // GetTitles operation middleware func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { @@ -854,6 +879,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } + router.POST(options.BaseURL+"/media/upload", wrapper.PostMediaUpload) router.GET(options.BaseURL+"/titles", wrapper.GetTitles) router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) router.GET(options.BaseURL+"/users/", wrapper.GetUsers) @@ -866,6 +892,49 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } +type PostMediaUploadRequestObject struct { + Body io.Reader + MultipartBody *multipart.Reader +} + +type PostMediaUploadResponseObject interface { + VisitPostMediaUploadResponse(w http.ResponseWriter) error +} + +type PostMediaUpload200JSONResponse Image + +func (response PostMediaUpload200JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload400JSONResponse string + +func (response PostMediaUpload400JSONResponse) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostMediaUpload415Response struct { +} + +func (response PostMediaUpload415Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(415) + return nil +} + +type PostMediaUpload500Response struct { +} + +func (response PostMediaUpload500Response) VisitPostMediaUploadResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type GetTitlesRequestObject struct { Params GetTitlesParams } @@ -1403,6 +1472,9 @@ func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.R // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Upload an image (PNG, JPEG, or WebP) + // (POST /media/upload) + PostMediaUpload(ctx context.Context, request PostMediaUploadRequestObject) (PostMediaUploadResponseObject, error) // Get titles // (GET /titles) GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) @@ -1447,6 +1519,43 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// PostMediaUpload operation middleware +func (sh *strictHandler) PostMediaUpload(ctx *gin.Context) { + var request PostMediaUploadRequestObject + + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "encoding") { + request.Body = ctx.Request.Body + } + if strings.HasPrefix(ctx.GetHeader("Content-Type"), "multipart/form-data") { + if reader, err := ctx.Request.MultipartReader(); err == nil { + request.MultipartBody = reader + } else { + ctx.Error(err) + return + } + } + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostMediaUpload(ctx, request.(PostMediaUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostMediaUpload") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(PostMediaUploadResponseObject); ok { + if err := validResponse.VisitPostMediaUploadResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetTitles operation middleware func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { var request GetTitlesRequestObject diff --git a/api/openapi.yaml b/api/openapi.yaml index 0759a54..26813fc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -19,7 +19,9 @@ paths: $ref: "./paths/users-id-titles.yaml" /users/{user_id}/titles/{title_id}: $ref: "./paths/users-id-titles-id.yaml" - + /media/upload: + $ref: "./paths/media_upload.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/media_upload.yaml b/api/paths/media_upload.yaml new file mode 100644 index 0000000..0453952 --- /dev/null +++ b/api/paths/media_upload.yaml @@ -0,0 +1,37 @@ +post: + summary: Upload an image (PNG, JPEG, or WebP) + description: | + Uploads a single image file. Supported formats: **PNG**, **JPEG/JPG**, **WebP**. + requestBody: + required: true + content: + multipart/form-data: + schema: + image: + type: string + format: binary + description: Image file (PNG, JPEG, or WebP) + encoding: + image: + contentType: image/png, image/jpeg, image/webp + + responses: + '200': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: "../schemas/Image.yaml" + '400': + description: Bad request — e.g., invalid/malformed image, empty file + content: + application/json: + schema: + type: string + '415': + description: | + Unsupported Media Type — e.g., request `Content-Type` is not `multipart/form-data`, + or the `image` part has an unsupported `Content-Type` (not image/png, image/jpeg, or image/webp) + + '500': + description: Internal server error \ No newline at end of file diff --git a/go.mod b/go.mod index 6662bc1..08a3dc1 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -42,12 +43,13 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.33.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/go.sum b/go.sum index 520a22b..dc41797 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -103,10 +105,18 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -114,11 +124,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -131,6 +145,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -144,12 +160,16 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go new file mode 100644 index 0000000..5309480 --- /dev/null +++ b/modules/backend/handlers/images.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + oapi "nyanimedb/api" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + log "github.com/sirupsen/logrus" + "golang.org/x/image/webp" +) + +// PostMediaUpload implements oapi.StrictServerInterface. +func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) { + // Получаем multipart body + mp := request.MultipartBody + if mp == nil { + log.Errorf("PostMedia without body") + return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil + } + + // Парсим первую часть (предполагаем, что файл в поле "file") + part, err := mp.NextPart() + if err != nil { + log.Errorf("PostMedia without file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + defer part.Close() + + // Читаем ВЕСЬ файл в память (для небольших изображений — нормально) + // Если файлы могут быть большими — используйте лимитированный буфер (см. ниже) + data, err := io.ReadAll(part) + if err != nil { + log.Errorf("PostMedia cannot read file") + return oapi.PostMediaUpload400JSONResponse("File required"), nil + } + + if len(data) == 0 { + log.Errorf("PostMedia empty file") + return oapi.PostMediaUpload400JSONResponse("Empty file"), nil + } + + // Проверка MIME по первым 512 байтам + mimeType := http.DetectContentType(data) + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + log.Errorf("PostMedia bad type") + return oapi.PostMediaUpload400JSONResponse("Bad data type"), nil + } + + // Декодируем изображение из буфера + var img image.Image + switch mimeType { + case "image/jpeg": + { + img, err = jpeg.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/png": + { + img, err = png.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + case "image/webp": + { + img, err = webp.Decode(bytes.NewReader(data)) + if err != nil { + log.Errorf("PostMedia cannot decode file: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + } + } + + // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.PNG) + if err != nil { + log.Errorf("PostMedia failed to re-encode JPEG: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + // TODO: to delete + filename := part.FileName() + if filename == "" { + filename = "upload_" + generateRandomHex(8) + ".jpg" + } else { + filename = sanitizeFilename(filename) + if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { + filename += ".jpg" + } + } + + // TODO: пойти на хуй ( вызвать файловую помойку) + err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + if err != nil { + log.Errorf("PostMedia failed to write: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + return oapi.PostMediaUpload200JSONResponse{}, nil +} + +// Вспомогательные функции — как раньше +func generateRandomHex(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = byte('a' + (i % 16)) + } + return fmt.Sprintf("%x", b) +} + +func sanitizeFilename(name string) string { + var clean strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '.' || r == '_' || r == '-' { + clean.WriteRune(r) + } + } + s := clean.String() + if s == "" { + return "file" + } + return s +} From 5acc53ec9d8bd34fc395bc5cd527d7935c470cad Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 04:34:18 +0300 Subject: [PATCH 13/17] fix --- modules/backend/handlers/images.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go index 5309480..c1e3d4b 100644 --- a/modules/backend/handlers/images.go +++ b/modules/backend/handlers/images.go @@ -85,7 +85,6 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo } } - // Перекодируем в чистый JPEG (без EXIF, сжатие, RGB) var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.PNG) if err != nil { @@ -99,13 +98,14 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo filename = "upload_" + generateRandomHex(8) + ".jpg" } else { filename = sanitizeFilename(filename) - if !strings.HasSuffix(strings.ToLower(filename), ".jpg") { - filename += ".jpg" + if !strings.HasSuffix(strings.ToLower(filename), ".png") { + filename += ".png" } } // TODO: пойти на хуй ( вызвать файловую помойку) - err = os.WriteFile(filepath.Join("/uploads", filename), buf.Bytes(), 0644) + os.Mkdir("uploads", 0644) + err = os.WriteFile(filepath.Join("./uploads", filename), buf.Bytes(), 0644) if err != nil { log.Errorf("PostMedia failed to write: %v", err) return oapi.PostMediaUpload500Response{}, nil From afb1db17bdaca025da47a8eb78c758278f71b6a3 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 04:51:04 +0300 Subject: [PATCH 14/17] feat: generateImpersonationToken function --- modules/auth/handlers/handlers.go | 37 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2c4ee6c..9138fa7 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -56,7 +56,7 @@ func (s Server) generateImpersonationToken(userID string, impersonated_by string at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", err } @@ -159,7 +159,7 @@ func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject return result, nil } -func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { +func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersonationTokenRequestObject) (auth.GetImpersonationTokenResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -167,11 +167,30 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper return auth.GetImpersonationToken200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - token := ginCtx.Request.Header.Get("Authorization") + token, err := ExtractBearerToken(ginCtx.Request.Header.Get("Authorization")) + if err != nil { + // TODO: return 500 + log.Errorf("failed to extract bearer token: %v", err) + return auth.GetImpersonationToken401Response{}, err + } log.Printf("got auth token: %s", token) - //s.db.GetExternalServiceByToken() - return auth.PostSignIn401Response{}, nil + ext_service, err := s.db.GetExternalServiceByToken(context.Background(), &token) + if err != nil { + log.Errorf("failed to get external service by token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + // TODO: handle tgid + accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + if err != nil { + log.Errorf("failed to generate impersonation token: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } // func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { @@ -266,3 +285,11 @@ func (s Server) GetImpersonationToken(ctx context.Context, request auth.GetImper // Error: errStr, // }, nil // } + +func ExtractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if len(header) <= len(prefix) || header[:len(prefix)] != prefix { + return "", fmt.Errorf("invalid bearer token format") + } + return header[len(prefix):], nil +} From 8bd515c33f081bd062013ac29ae3e6313848de13 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 05:15:21 +0300 Subject: [PATCH 15/17] fix: GetImpersonationToken external_id handling --- auth/auth.gen.go | 6 +-- auth/openapi-auth.yaml | 4 +- modules/auth/handlers/handlers.go | 71 ++++++++++++------------------- modules/auth/queries.sql | 8 +++- sql/migrations/000001_init.up.sql | 2 +- sql/models.go | 2 +- sql/queries.sql.go | 29 +++++++++++++ 7 files changed, 70 insertions(+), 52 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 89a2168..1e8803e 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -19,9 +19,9 @@ const ( // GetImpersonationTokenJSONBody defines parameters for GetImpersonationToken. type GetImpersonationTokenJSONBody struct { - TgId *int64 `json:"tg_id,omitempty"` - UserId *int64 `json:"user_id,omitempty"` - union json.RawMessage + ExternalId *int64 `json:"external_id,omitempty"` + UserId *int64 `json:"user_id,omitempty"` + union json.RawMessage } // GetImpersonationTokenJSONBody0 defines parameters for GetImpersonationToken. diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 93db937..803a4ae 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -94,12 +94,12 @@ paths: user_id: type: integer format: int64 - tg_id: + external_id: type: integer format: int64 oneOf: - required: ["user_id"] - - required: ["tg_id"] + - required: ["external_id"] responses: "200": description: Generated impersonation access token diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9138fa7..2a6518e 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -182,8 +182,33 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona // TODO: check err and retyrn 400/500 } - // TODO: handle tgid - accessToken, err := s.generateImpersonationToken(fmt.Sprintf("%d", *req.Body.UserId), fmt.Sprintf("%d", ext_service.ID)) + var user_id string = "" + + if req.Body.ExternalId != nil { + user, err := s.db.GetUserByExternalServiceId(context.Background(), sqlc.GetUserByExternalServiceIdParams{ + ExternalID: fmt.Sprintf("%d", *req.Body.ExternalId), + ServiceID: ext_service.ID, + }) + if err != nil { + log.Errorf("failed to get user by external user id: %v", err) + return auth.GetImpersonationToken401Response{}, err + // TODO: check err and retyrn 400/500 + } + + user_id = fmt.Sprintf("%d", user.ID) + } + + if req.Body.UserId != nil { + if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { + log.Error("user_id and external_d are incorrect") + // TODO: 405 + return auth.GetImpersonationToken401Response{}, nil + } else { + user_id = fmt.Sprintf("%d", *req.Body.UserId) + } + } + + accessToken, err := s.generateImpersonationToken(user_id, fmt.Sprintf("%d", ext_service.ID)) if err != nil { log.Errorf("failed to generate impersonation token: %v", err) return auth.GetImpersonationToken401Response{}, err @@ -193,48 +218,6 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona return auth.GetImpersonationToken200JSONResponse{AccessToken: accessToken}, nil } -// func (s Server) PostAuthVerifyToken(ctx context.Context, req auth.PostAuthVerifyTokenRequestObject) (auth.PostAuthVerifyTokenResponseObject, error) { -// valid := false -// var userID *string -// var errStr *string - -// token, err := jwt.Parse(req.Body.Token, func(t *jwt.Token) (interface{}, error) { -// if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { -// return nil, fmt.Errorf("unexpected signing method") -// } -// return accessSecret, nil -// }) - -// if err != nil { -// e := err.Error() -// errStr = &e -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } - -// if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { -// if uid, ok := claims["user_id"].(string); ok { -// valid = true -// userID = &uid -// } else { -// e := "user_id not found in token" -// errStr = &e -// } -// } else { -// e := "invalid token claims" -// errStr = &e -// } - -// return auth.PostAuthVerifyToken200JSONResponse{ -// Valid: &valid, -// UserId: userID, -// Error: errStr, -// }, nil -// } - // func (s Server) PostAuthRefreshToken(ctx context.Context, req auth.PostAuthRefreshTokenRequestObject) (auth.PostAuthRefreshTokenResponseObject, error) { // valid := false // var userID *string diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql index 363f07a..0b9b941 100644 --- a/modules/auth/queries.sql +++ b/modules/auth/queries.sql @@ -12,4 +12,10 @@ RETURNING id; -- name: GetExternalServiceByToken :one SELECT * FROM external_services -WHERE auth_token = sqlc.arg('auth_token'); \ No newline at end of file +WHERE auth_token = sqlc.arg('auth_token'); + +-- name: GetUserByExternalServiceId :one +SELECT u.* +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = sqlc.arg('external_id') AND ei.service_id = sqlc.arg('service_id'); \ No newline at end of file diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 9bf99dc..946fe7e 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -112,7 +112,7 @@ CREATE TABLE external_services ( CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), - service_id bigint REFERENCES external_services (id), + service_id bigint NOT NULL REFERENCES external_services (id), external_id text NOT NULL ); diff --git a/sql/models.go b/sql/models.go index 1395a19..c299609 100644 --- a/sql/models.go +++ b/sql/models.go @@ -188,7 +188,7 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { type ExternalID struct { UserID int64 `json:"user_id"` - ServiceID *int64 `json:"service_id"` + ServiceID int64 `json:"service_id"` ExternalID string `json:"external_id"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index e12619e..2d4067d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -251,6 +251,35 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMe return items, nil } +const getUserByExternalServiceId = `-- name: GetUserByExternalServiceId :one +SELECT u.id, u.avatar_id, u.passhash, u.mail, u.nickname, u.disp_name, u.user_desc, u.creation_date, u.last_login +FROM users u +LEFT JOIN external_ids ei ON eu.user_id = u.id +WHERE ei.external_id = $1 AND ei.service_id = $2 +` + +type GetUserByExternalServiceIdParams struct { + ExternalID string `json:"external_id"` + ServiceID int64 `json:"service_id"` +} + +func (q *Queries) GetUserByExternalServiceId(ctx context.Context, arg GetUserByExternalServiceIdParams) (User, error) { + row := q.db.QueryRow(ctx, getUserByExternalServiceId, arg.ExternalID, arg.ServiceID) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + const getUserByID = `-- name: GetUserByID :one SELECT t.id as id, From 00894f45266d2c67f108674cce5130752ff3dfd9 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Sat, 6 Dec 2025 05:18:23 +0300 Subject: [PATCH 16/17] feat: ftime logic for usertitle is changed --- api/_build/openapi.yaml | 6 +++ api/api.gen.go | 6 ++- api/paths/users-id-titles-id.yaml | 3 ++ api/paths/users-id-titles.yaml | 3 ++ modules/backend/handlers/users.go | 12 +++++ modules/backend/queries.sql | 8 +-- modules/frontend/src/api/client.gen.ts | 2 +- modules/frontend/src/api/sdk.gen.ts | 7 ++- modules/frontend/src/api/types.gen.ts | 50 +++++++++++++++++++ .../src/pages/UsersPage/UsersPage.tsx | 0 sql/migrations/000001_init.up.sql | 15 +----- sql/queries.sql.go | 36 +++++++------ 12 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 modules/frontend/src/pages/UsersPage/UsersPage.tsx diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 9ed5b5f..ad0c9be 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -395,6 +395,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time required: - title_id - status @@ -478,6 +481,9 @@ paths: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/api.gen.go b/api/api.gen.go index d93e925..04d10c0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -262,7 +262,8 @@ type GetUserTitlesParams struct { // AddUserTitleJSONBody defines parameters for AddUserTitle. type AddUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status UserTitleStatus `json:"status"` @@ -271,7 +272,8 @@ type AddUserTitleJSONBody struct { // UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. type UpdateUserTitleJSONBody struct { - Rate *int32 `json:"rate,omitempty"` + Ftime *time.Time `json:"ftime,omitempty"` + Rate *int32 `json:"rate,omitempty"` // Status User's title status Status *UserTitleStatus `json:"status,omitempty"` diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index 1da2b81..20a174f 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -61,6 +61,9 @@ patch: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully updated diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 75f5461..f1e5e95 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -122,6 +122,9 @@ post: rate: type: integer format: int32 + ftime: + type: string + format: date-time responses: '200': description: Title successfully added to user diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 995d5af..eecd82f 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -69,6 +69,16 @@ func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { return nil } +func oapiDate2sql(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{ + Time: *t, + Valid: true, + } +} + // func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { // var sqlc_status SqlcUserStatus // if s == nil { @@ -365,6 +375,7 @@ func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleReque TitleID: request.Body.TitleId, Status: *status, Rate: request.Body.Rate, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.InsertUserTitle(ctx, params) @@ -428,6 +439,7 @@ func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitl Rate: request.Body.Rate, UserID: request.UserId, TitleID: request.TitleId, + Ftime: oapiDate2sql(request.Body.Ftime), } user_title, err := s.db.UpdateUserTitle(ctx, params) diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 03502c4..19971e5 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -400,13 +400,14 @@ FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; -- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( sqlc.arg('user_id')::bigint, sqlc.arg('title_id')::bigint, sqlc.arg('status')::usertitle_status_t, sqlc.narg('rate')::int, - sqlc.narg('review_id')::bigint + sqlc.narg('review_id')::bigint, + sqlc.narg('ftime')::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime; @@ -415,7 +416,8 @@ RETURNING user_id, title_id, status, rate, review_id, ctime; UPDATE usertitles SET status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), - rate = COALESCE(sqlc.narg('rate')::int, rate) + rate = COALESCE(sqlc.narg('rate')::int, rate), + ctime = COALESCE(sqlc.narg('ftime')::timestamptz, ctime) WHERE user_id = sqlc.arg('user_id') AND title_id = sqlc.arg('title_id') diff --git a/modules/frontend/src/api/client.gen.ts b/modules/frontend/src/api/client.gen.ts index 2de06ac..952c663 100644 --- a/modules/frontend/src/api/client.gen.ts +++ b/modules/frontend/src/api/client.gen.ts @@ -13,4 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig = (override?: Config) => Config & T>; -export const client = createClient(createConfig({ baseUrl: 'http://10.1.0.65:8081/api/v1' })); +export const client = createClient(createConfig({ baseUrl: '/api/v1' })); diff --git a/modules/frontend/src/api/sdk.gen.ts b/modules/frontend/src/api/sdk.gen.ts index 5359156..7d46120 100644 --- a/modules/frontend/src/api/sdk.gen.ts +++ b/modules/frontend/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; +import type { AddUserTitleData, AddUserTitleErrors, AddUserTitleResponses, DeleteUserTitleData, DeleteUserTitleErrors, DeleteUserTitleResponses, GetTitleData, GetTitleErrors, GetTitleResponses, GetTitlesData, GetTitlesErrors, GetTitlesResponses, GetUsersData, GetUsersErrors, GetUsersIdData, GetUsersIdErrors, GetUsersIdResponses, GetUsersResponses, GetUserTitleData, GetUserTitleErrors, GetUserTitleResponses, GetUserTitlesData, GetUserTitlesErrors, GetUserTitlesResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UpdateUserTitleData, UpdateUserTitleErrors, UpdateUserTitleResponses } from './types.gen'; export type Options = Options2 & { /** @@ -32,6 +32,11 @@ export const getTitles = (options?: Option */ export const getTitle = (options: Options) => (options.client ?? client).get({ url: '/titles/{title_id}', ...options }); +/** + * Search user by nickname or dispname (both in one param), response is always sorted by id + */ +export const getUsers = (options?: Options) => (options?.client ?? client).get({ url: '/users/', ...options }); + /** * Get user info */ diff --git a/modules/frontend/src/api/types.gen.ts b/modules/frontend/src/api/types.gen.ts index ce4db4b..d4526a7 100644 --- a/modules/frontend/src/api/types.gen.ts +++ b/modules/frontend/src/api/types.gen.ts @@ -60,6 +60,12 @@ export type Title = { title_names: { [key: string]: Array; }; + /** + * Localized description. Key = language (ISO 639-1), value = description. + */ + title_desc?: { + [key: string]: string; + }; studio?: Studio; tags: Tags; poster?: Image; @@ -231,6 +237,50 @@ export type GetTitleResponses = { export type GetTitleResponse = GetTitleResponses[keyof GetTitleResponses]; +export type GetUsersData = { + body?: never; + path?: never; + query?: { + word?: string; + limit?: number; + /** + * pass cursor naked + */ + cursor_id?: number; + }; + url: '/users/'; +}; + +export type GetUsersErrors = { + /** + * Request params are not correct + */ + 400: unknown; + /** + * Unknown server error + */ + 500: unknown; +}; + +export type GetUsersResponses = { + /** + * List of users with cursor + */ + 200: { + /** + * List of users + */ + data: Array; + cursor: number; + }; + /** + * No users found + */ + 204: void; +}; + +export type GetUsersResponse = GetUsersResponses[keyof GetUsersResponses]; + export type GetUsersIdData = { body?: never; path: { diff --git a/modules/frontend/src/pages/UsersPage/UsersPage.tsx b/modules/frontend/src/pages/UsersPage/UsersPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index d6353d6..d57b807 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -170,17 +170,4 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); - -CREATE OR REPLACE FUNCTION set_ctime() -RETURNS TRIGGER AS $$ -BEGIN - NEW.ctime = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_ctime_on_update -BEFORE UPDATE ON usertitles -FOR EACH ROW -EXECUTE FUNCTION set_ctime(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0c17599..d253cc9 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -9,6 +9,8 @@ import ( "context" "encoding/json" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -394,23 +396,25 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams } const insertUserTitle = `-- name: InsertUserTitle :one -INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +INSERT INTO usertitles (user_id, title_id, status, rate, review_id, ctime) VALUES ( $1::bigint, $2::bigint, $3::usertitle_status_t, $4::int, - $5::bigint + $5::bigint, + $6::timestamptz ) RETURNING user_id, title_id, status, rate, review_id, ctime ` type InsertUserTitleParams struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ftime pgtype.Timestamptz `json:"ftime"` } func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { @@ -420,6 +424,7 @@ func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams arg.Status, arg.Rate, arg.ReviewID, + arg.Ftime, ) var i Usertitle err := row.Scan( @@ -1017,18 +1022,20 @@ const updateUserTitle = `-- name: UpdateUserTitle :one UPDATE usertitles SET status = COALESCE($1::usertitle_status_t, status), - rate = COALESCE($2::int, rate) + rate = COALESCE($2::int, rate), + ctime = COALESCE($3::timestamptz, ctime) WHERE - user_id = $3 - AND title_id = $4 + user_id = $4 + AND title_id = $5 RETURNING user_id, title_id, status, rate, review_id, ctime ` type UpdateUserTitleParams struct { - Status *UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + Ftime pgtype.Timestamptz `json:"ftime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` } // Fails with sql.ErrNoRows if (user_id, title_id) not found @@ -1036,6 +1043,7 @@ func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams row := q.db.QueryRow(ctx, updateUserTitle, arg.Status, arg.Rate, + arg.Ftime, arg.UserID, arg.TitleID, ) From 6955216568c9db4cdb49d9771f333632010d5364 Mon Sep 17 00:00:00 2001 From: nihonium Date: Sat, 6 Dec 2025 05:24:29 +0300 Subject: [PATCH 17/17] feat(cicd): added redis --- deploy/docker-compose.yml | 25 +++++++++++++++++++++---- modules/auth/handlers/handlers.go | 1 + 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1119335..3eff3d3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -40,6 +40,22 @@ services: retries: 5 start_period: 10s + redis: + image: redis:8.4.0-alpine + container_name: redis + ports: + - "6379:6379" + restart: always + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + volumes: + - redis_data:/data + nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest container_name: nyanimedb-backend @@ -51,8 +67,8 @@ services: RABBITMQ_URL: ${RABBITMQ_URL} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} AUTH_ENABLED: ${AUTH_ENABLED} - ports: - - "8080:8080" + # ports: + # - "8080:8080" depends_on: - postgres - rabbitmq @@ -68,8 +84,8 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - ports: - - "8082:8082" + # ports: + # - "8082:8082" depends_on: - postgres networks: @@ -89,6 +105,7 @@ services: volumes: postgres_data: rabbitmq_data: + redis_data: networks: nyanimedb-network: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 2a6518e..3af44f3 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -199,6 +199,7 @@ func (s Server) GetImpersonationToken(ctx context.Context, req auth.GetImpersona } if req.Body.UserId != nil { + // TODO: check user existence if user_id != "" && user_id != fmt.Sprintf("%d", *req.Body.UserId) { log.Error("user_id and external_d are incorrect") // TODO: 405