From 669349e020091bda697a8f5f86d98633f3ad5b9a Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Fri, 1 Aug 2025 04:34:06 +0300 Subject: [PATCH] chore: remove direct avatar upload endpoint (POST /profile/avatar); feat: add endpoints for presigned upload URLs (GET /upload/avatar, GET /upload/image); refactor: replace ProfileDto with NewProfileDto in update profile endpoint; feat: implement S3 integration for avatar management; fix: update database queries to handle new avatar upload flow; chore: add new dependencies for S3 handling (golang.org/x/time); refactor: rename UploadService to S3Service; refactor: change return type for func LocalizeS3Url(originalURL string) (*url.URL, error); feat: add custom validator for upload_id --- backend/docs/docs.go | 148 ++++++++++++------ backend/docs/swagger.json | 148 ++++++++++++------ backend/docs/swagger.yaml | 95 +++++++---- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/controllers/profile.go | 42 +---- .../internal/controllers/{upload.go => s3.go} | 16 +- backend/internal/database/query.sql.go | 10 +- backend/internal/dto/profile.go | 19 ++- backend/internal/models/{upload.go => s3.go} | 0 backend/internal/services/profile.go | 28 ++-- .../internal/services/{upload.go => s3.go} | 89 ++++++++--- backend/internal/utils/minio.go | 9 +- backend/internal/validation/custom.go | 14 ++ sqlc/query.sql | 6 +- 15 files changed, 405 insertions(+), 222 deletions(-) rename backend/internal/controllers/{upload.go => s3.go} (86%) rename backend/internal/models/{upload.go => s3.go} (100%) rename backend/internal/services/{upload.go => s3.go} (51%) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 7686b96..a3ac550 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -318,7 +318,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/dto.ProfileDto" + "$ref": "#/definitions/dto.NewProfileDto" } } ], @@ -332,42 +332,6 @@ const docTemplate = `{ } } }, - "/profile/avatar": { - "post": { - "security": [ - { - "JWT": [] - } - ], - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Profile" - ], - "summary": "Upload an avatar", - "parameters": [ - { - "type": "file", - "description": "Avatar image file", - "name": "file", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "Uploaded image url", - "schema": { - "$ref": "#/definitions/dto.UrlDto" - } - } - } - } - }, "/profile/settings": { "get": { "security": [ @@ -495,14 +459,97 @@ const docTemplate = `{ } } } + }, + "/upload/avatar": { + "get": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Upload" + ], + "summary": "Get presigned URL for avatar upload", + "responses": { + "200": { + "description": "Presigned URL and form data", + "schema": { + "$ref": "#/definitions/models.PresignedUploadResponse" + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/upload/image": { + "get": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Upload" + ], + "summary": "Get presigned URL for image upload", + "responses": { + "200": { + "description": "Presigned URL and form data", + "schema": { + "$ref": "#/definitions/models.PresignedUploadResponse" + } + }, + "500": { + "description": "Internal server error" + } + } + } } }, "definitions": { - "dto.ProfileDto": { + "dto.NewProfileDto": { "type": "object", "required": [ "name" ], + "properties": { + "avatar_upload_id": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "birthday": { + "type": "integer" + }, + "color": { + "type": "string" + }, + "color_grad": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "dto.ProfileDto": { + "type": "object", "properties": { "avatar_url": { "type": "string" @@ -550,17 +597,6 @@ const docTemplate = `{ } } }, - "dto.UrlDto": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "models.ChangePasswordRequest": { "type": "object", "required": [ @@ -663,6 +699,20 @@ const docTemplate = `{ } } }, + "models.PresignedUploadResponse": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + }, "models.RefreshRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 8cde3c8..38c280c 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -314,7 +314,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/dto.ProfileDto" + "$ref": "#/definitions/dto.NewProfileDto" } } ], @@ -328,42 +328,6 @@ } } }, - "/profile/avatar": { - "post": { - "security": [ - { - "JWT": [] - } - ], - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Profile" - ], - "summary": "Upload an avatar", - "parameters": [ - { - "type": "file", - "description": "Avatar image file", - "name": "file", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "Uploaded image url", - "schema": { - "$ref": "#/definitions/dto.UrlDto" - } - } - } - } - }, "/profile/settings": { "get": { "security": [ @@ -491,14 +455,97 @@ } } } + }, + "/upload/avatar": { + "get": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Upload" + ], + "summary": "Get presigned URL for avatar upload", + "responses": { + "200": { + "description": "Presigned URL and form data", + "schema": { + "$ref": "#/definitions/models.PresignedUploadResponse" + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/upload/image": { + "get": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Upload" + ], + "summary": "Get presigned URL for image upload", + "responses": { + "200": { + "description": "Presigned URL and form data", + "schema": { + "$ref": "#/definitions/models.PresignedUploadResponse" + } + }, + "500": { + "description": "Internal server error" + } + } + } } }, "definitions": { - "dto.ProfileDto": { + "dto.NewProfileDto": { "type": "object", "required": [ "name" ], + "properties": { + "avatar_upload_id": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "birthday": { + "type": "integer" + }, + "color": { + "type": "string" + }, + "color_grad": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "dto.ProfileDto": { + "type": "object", "properties": { "avatar_url": { "type": "string" @@ -546,17 +593,6 @@ } } }, - "dto.UrlDto": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "models.ChangePasswordRequest": { "type": "object", "required": [ @@ -659,6 +695,20 @@ } } }, + "models.PresignedUploadResponse": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + }, "models.RefreshRequest": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 87f9a88..b1345be 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,22 @@ basePath: /api/ definitions: + dto.NewProfileDto: + properties: + avatar_upload_id: + type: string + bio: + type: string + birthday: + type: integer + color: + type: string + color_grad: + type: string + name: + type: string + required: + - name + type: object dto.ProfileDto: properties: avatar_url: @@ -14,8 +31,6 @@ definitions: type: string name: type: string - required: - - name type: object dto.ProfileSettingsDto: properties: @@ -34,13 +49,6 @@ definitions: hide_profile_details: type: boolean type: object - dto.UrlDto: - properties: - url: - type: string - required: - - url - type: object models.ChangePasswordRequest: properties: old_password: @@ -109,6 +117,15 @@ definitions: refresh_token: type: string type: object + models.PresignedUploadResponse: + properties: + fields: + additionalProperties: + type: string + type: object + url: + type: string + type: object models.RefreshRequest: properties: refresh_token: @@ -351,7 +368,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/dto.ProfileDto' + $ref: '#/definitions/dto.NewProfileDto' produces: - application/json responses: @@ -390,28 +407,6 @@ paths: summary: Get profile by username tags: - Profile - /profile/avatar: - post: - consumes: - - multipart/form-data - parameters: - - description: Avatar image file - in: formData - name: file - required: true - type: file - produces: - - application/json - responses: - "200": - description: Uploaded image url - schema: - $ref: '#/definitions/dto.UrlDto' - security: - - JWT: [] - summary: Upload an avatar - tags: - - Profile /profile/settings: get: consumes: @@ -465,6 +460,42 @@ paths: summary: Get health status tags: - Service + /upload/avatar: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: Presigned URL and form data + schema: + $ref: '#/definitions/models.PresignedUploadResponse' + "500": + description: Internal server error + security: + - JWT: [] + summary: Get presigned URL for avatar upload + tags: + - Upload + /upload/image: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: Presigned URL and form data + schema: + $ref: '#/definitions/models.PresignedUploadResponse' + "500": + description: Internal server error + security: + - JWT: [] + summary: Get presigned URL for image upload + tags: + - Upload schemes: - http securityDefinitions: diff --git a/backend/go.mod b/backend/go.mod index 16b4b0a..cb6b1ab 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -74,6 +74,7 @@ require ( golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7284731..718b4b3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -216,6 +216,8 @@ golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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= diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go index b998e68..45ee316 100644 --- a/backend/internal/controllers/profile.go +++ b/backend/internal/controllers/profile.go @@ -24,7 +24,6 @@ import ( "easywish/internal/utils/enums" "errors" "net/http" - "os" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -78,13 +77,6 @@ func NewProfileController(_log *zap.Logger, _ps services.ProfileService) Control Middleware: []gin.HandlerFunc{}, Function: ctrl.updateProfileSettings, }, - { - HttpMethod: POST, - Path: "/avatar", - Authorization: enums.UserRole, - Middleware: []gin.HandlerFunc{}, - Function: ctrl.uploadAvatar, - }, }, } } @@ -169,11 +161,11 @@ func (ctrl *ProfileController) getProfileSettings(c *gin.Context) { // @Accept json // @Produce json // @Security JWT -// @Param request body dto.ProfileDto true " " +// @Param request body dto.NewProfileDto true " " // @Success 200 {object} bool " " // @Router /profile [put] func (ctrl *ProfileController) updateProfile(c *gin.Context) { - request, err := GetRequest[dto.ProfileDto](c); if err != nil { + request, err := GetRequest[dto.NewProfileDto](c); if err != nil { return } @@ -206,33 +198,3 @@ func (ctrl *ProfileController) updateProfileSettings(c *gin.Context) { c.JSON(http.StatusOK, response) } - -// XXX: untested -// @Summary Upload an avatar -// @Tags Profile -// @Accept mpfd -// @Produce json -// @Security JWT -// @Param file formData file true "Avatar image file" -// @Success 200 {object} dto.UrlDto "Uploaded image url" -// @Router /profile/avatar [post] -func (ctrl *ProfileController) uploadAvatar(c *gin.Context) { - cinfo := GetClientInfo(c) - - allowedTypes := map[string]bool{ - "image/jpeg": true, - "image/png": true, - "image/webp": true, - } - fileName, err := GetFile(c, "file", 8*1024*1024, allowedTypes); if err != nil { - return - } - defer os.Remove(*fileName) - - link, err := ctrl.ps.UploadAvatar(cinfo, *fileName); if err != nil { - c.Status(http.StatusInternalServerError) - return - } - - c.JSON(http.StatusOK, dto.UrlDto{Url: *link}) -} diff --git a/backend/internal/controllers/upload.go b/backend/internal/controllers/s3.go similarity index 86% rename from backend/internal/controllers/upload.go rename to backend/internal/controllers/s3.go index feb5caf..3adee0f 100644 --- a/backend/internal/controllers/upload.go +++ b/backend/internal/controllers/s3.go @@ -30,13 +30,13 @@ import ( "golang.org/x/time/rate" ) -type UploadController struct { +type S3Controller struct { log *zap.Logger - us services.UploadService + s3 services.S3Service } -func NewUploadController(_log *zap.Logger, _us services.UploadService) Controller { - ctrl := UploadController{log: _log, us: _us} +func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller { + ctrl := S3Controller{log: _log, s3: _us} return &controllerImpl{ Path: "/upload", @@ -71,8 +71,8 @@ func NewUploadController(_log *zap.Logger, _us services.UploadService) Controlle // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" // @Failure 500 "Internal server error" // @Router /upload/avatar [get] -func (ctrl *UploadController) getAvatarUploadUrl(c *gin.Context) { - url, formData, err := ctrl.us.GetAvatarUrl() +func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) { + url, formData, err := ctrl.s3.CreateAvatarUrl() if err != nil { ctrl.log.Error("Failed to generate avatar upload URL", zap.Error(err)) c.Status(http.StatusInternalServerError) @@ -94,8 +94,8 @@ func (ctrl *UploadController) getAvatarUploadUrl(c *gin.Context) { // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" // @Failure 500 "Internal server error" // @Router /upload/image [get] -func (ctrl *UploadController) getImageUploadUrl(c *gin.Context) { - url, formData, err := ctrl.us.GetImageUrl() +func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) { + url, formData, err := ctrl.s3.CreateImageUrl() if err != nil { c.Status(http.StatusInternalServerError) return diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index 3ce6d70..a85cf12 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -990,9 +990,9 @@ SET name = COALESCE($2, name), bio = COALESCE($3, bio), birthday = COALESCE($4, birthday), - avatar_url = COALESCE($5, avatar_url), - color = COALESCE($6, color), - color_grad = COALESCE($7, color_grad) + avatar_url = COALESCE($7, avatar_url), + color = COALESCE($5, color), + color_grad = COALESCE($6, color_grad) FROM users WHERE username = $1 ` @@ -1002,9 +1002,9 @@ type UpdateProfileByUsernameParams struct { Name string Bio string Birthday pgtype.Timestamp - AvatarUrl string Color string ColorGrad string + AvatarUrl *string } func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfileByUsernameParams) error { @@ -1013,9 +1013,9 @@ func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfile arg.Name, arg.Bio, arg.Birthday, - arg.AvatarUrl, arg.Color, arg.ColorGrad, + arg.AvatarUrl, ) return err } diff --git a/backend/internal/dto/profile.go b/backend/internal/dto/profile.go index 3c40915..21ec198 100644 --- a/backend/internal/dto/profile.go +++ b/backend/internal/dto/profile.go @@ -18,12 +18,21 @@ package dto type ProfileDto struct { - Name string `json:"name" binding:"required" validate:"name"` - Bio string `json:"bio" validate:"bio"` - AvatarUrl string `json:"avatar_url"` + Name string `json:"name"` + Bio string `json:"bio"` + AvatarUrl *string `json:"avatar_url"` Birthday int64 `json:"birthday"` - Color string `json:"color" validate:"color_hex"` - ColorGrad string `json:"color_grad" validate:"color_hex"` + Color string `json:"color"` + ColorGrad string `json:"color_grad"` +} + +type NewProfileDto struct { + Name string `json:"name" binding:"required" validate:"name"` + Bio string `json:"bio" validate:"bio"` + AvatarUploadID *string `json:"avatar_upload_id" validate:"upload_id=avatars"` + Birthday int64 `json:"birthday"` + Color string `json:"color" validate:"color_hex"` + ColorGrad string `json:"color_grad" validate:"color_hex"` } type ProfileSettingsDto struct { diff --git a/backend/internal/models/upload.go b/backend/internal/models/s3.go similarity index 100% rename from backend/internal/models/upload.go rename to backend/internal/models/s3.go diff --git a/backend/internal/services/profile.go b/backend/internal/services/profile.go index 4d9bea2..1c4fdf8 100644 --- a/backend/internal/services/profile.go +++ b/backend/internal/services/profile.go @@ -21,6 +21,7 @@ import ( "easywish/internal/database" "easywish/internal/dto" errs "easywish/internal/errors" + "easywish/internal/utils" mapspecial "easywish/internal/utils/mapSpecial" "errors" "time" @@ -36,10 +37,9 @@ import ( type ProfileService interface { GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) - UpdateProfile(cinfo dto.ClientInfo, newProfile dto.ProfileDto) (bool, error) + UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) - UploadAvatar(cinfo dto.ClientInfo, filePath string) (*string, error) } type profileServiceImpl struct { @@ -47,6 +47,7 @@ type profileServiceImpl struct { dbctx database.DbContext redis *redis.Client minio *minio.Client + s3 S3Service } func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client) ProfileService { @@ -117,12 +118,12 @@ func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.Prof } // XXX: no validation for timestamps' allowed ranges -func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.ProfileDto) (bool, error) { +func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { p.log.Error( "Failed to open transaction", zap.Error(err)) - return false, err + return false, errs.ErrServerError } defer helper.Rollback() @@ -131,11 +132,25 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto. Valid: true, } + var avatarUrl *string + if newProfile.AvatarUploadID != nil { + key, err := p.s3.SaveUpload(*newProfile.AvatarUploadID, "avatars"); if err != nil { + p.log.Error("Failed to save avatar", + zap.String("upload_id", *newProfile.AvatarUploadID), + zap.Error(err)) + return false, errs.ErrServerError + } + + urlObj := p.s3.GetLocalizedFileUrl(*key, "avatars") + avatarUrl = utils.NewPointer(urlObj.String()) + } + err = db.TXlessQueries.UpdateProfileByUsername(db.CTX, database.UpdateProfileByUsernameParams{ Username: cinfo.Username, Name: newProfile.Name, Bio: newProfile.Bio, Birthday: birthdayTimestamp, + AvatarUrl: avatarUrl, }); if err != nil { p.log.Error( "Failed to update user profile", @@ -193,8 +208,3 @@ func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProf return true, nil } - -// TODO: implement S3 before I can do anything with it -func (p *profileServiceImpl) UploadAvatar(cinfo dto.ClientInfo, filePath string) (*string, error) { - panic("unimplemented") -} diff --git a/backend/internal/services/upload.go b/backend/internal/services/s3.go similarity index 51% rename from backend/internal/services/upload.go rename to backend/internal/services/s3.go index 22dae44..d94449e 100644 --- a/backend/internal/services/upload.go +++ b/backend/internal/services/s3.go @@ -19,8 +19,11 @@ package services import ( "context" + "easywish/config" minioclient "easywish/internal/minioClient" "easywish/internal/utils" + "fmt" + "net/url" "time" "github.com/google/uuid" @@ -28,21 +31,24 @@ import ( "go.uber.org/zap" ) -type UploadService interface { - GetAvatarUrl() (*string, *map[string]string, error) - GetImageUrl() (*string, *map[string]string, error) +type S3Service interface { + CreateAvatarUrl() (*string, *map[string]string, error) + CreateImageUrl() (*string, *map[string]string, error) + SaveUpload(uploadID string, bucket string) (*string, error) + + GetLocalizedFileUrl(key string, bucket string) url.URL } -type uploadServiceImpl struct { +type s3ServiceImpl struct { minio *minio.Client log *zap.Logger avatarPolicy minio.PostPolicy - imagePolicy minio.PostPolicy + imagePolicy minio.PostPolicy } -func NewUploadService(_minio *minio.Client, _log *zap.Logger) UploadService { - service := uploadServiceImpl{ +func NewUploadService(_minio *minio.Client, _log *zap.Logger) S3Service { + service := s3ServiceImpl{ minio: _minio, log: _log, } @@ -76,41 +82,88 @@ func NewUploadService(_minio *minio.Client, _log *zap.Logger) UploadService { return &service } -func (u *uploadServiceImpl) genUrl(policy minio.PostPolicy, prefix string) (*string, *map[string]string, error) { +func (s *s3ServiceImpl) genUrl(policy minio.PostPolicy, prefix string) (*string, *map[string]string, error) { object := prefix + uuid.New().String() if err := policy.SetKey(object); err != nil { - u.log.Error( + s.log.Error( "Failed to set random key for presigned url", zap.Error(err)) return nil, nil, err } - url, formData, err := u.minio.PresignedPostPolicy(context.Background(), &policy) + url, formData, err := s.minio.PresignedPostPolicy(context.Background(), &policy) if err != nil { - u.log.Error( + s.log.Error( "Failed to generate presigned url", zap.String("object", object), zap.Error(err)) return nil, nil, err } - convertedUrl, err := utils.LocalizeS3Url(url.String()); if err != nil { - u.log.Error( + convertedUrl, err := utils.LocalizeS3Url(url.String()) + if err != nil { + s.log.Error( "Failed to localize object URL to user-accessible format", zap.String("url", url.String()), zap.Error(err)) return nil, nil, err } - return &convertedUrl, &formData, nil + return utils.NewPointer(convertedUrl.String()), &formData, nil } -func (u *uploadServiceImpl) GetAvatarUrl() (*string, *map[string]string, error) { - return u.genUrl(u.avatarPolicy, "avatar-") +func (s *s3ServiceImpl) CreateAvatarUrl() (*string, *map[string]string, error) { + return s.genUrl(s.avatarPolicy, "avatar-") } -func (u *uploadServiceImpl) GetImageUrl() (*string, *map[string]string, error) { - return u.genUrl(u.imagePolicy, "image-") +func (s *s3ServiceImpl) CreateImageUrl() (*string, *map[string]string, error) { + return s.genUrl(s.imagePolicy, "image-") +} + +func (s *s3ServiceImpl) SaveUpload(uploadID string, bucketAlias string) (*string, error) { + sourceBucket := minioclient.Buckets["uploads"] + bucket := minioclient.Buckets[bucketAlias] + newObjectKey := uuid.New().String() + + _, err := s.minio.CopyObject(context.Background(), minio.CopyDestOptions{ + Bucket: bucket, + Object: newObjectKey, + }, minio.CopySrcOptions{ + Bucket: sourceBucket, + Object: uploadID, + }) + if err != nil { + s.log.Error( + "Failed to copy object to new bucket", + zap.String("sourceBucket", sourceBucket), + zap.String("uploadID", uploadID), + zap.String("destinationBucket", bucket), + zap.String("newObjectKey", newObjectKey), + zap.Error(err)) + return nil, err + } + + err = s.minio.RemoveObject(context.Background(), sourceBucket, uploadID, minio.RemoveObjectOptions{}) + if err != nil { + s.log.Error( + "Failed to remove original object from uploads bucket", + zap.String("sourceBucket", sourceBucket), + zap.String("uploadID", uploadID), + zap.Error(err)) + return nil, err + } + + return &newObjectKey, nil +} + +func (s *s3ServiceImpl) GetLocalizedFileUrl(key string, bucketAlias string) url.URL { + cfg := config.GetConfig() + + return url.URL{ + Scheme: "http", + Host: fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), + Path: fmt.Sprintf("/s3/%s/%s", minioclient.Buckets[bucketAlias], key), + } } diff --git a/backend/internal/utils/minio.go b/backend/internal/utils/minio.go index 8d200d4..29c9e47 100644 --- a/backend/internal/utils/minio.go +++ b/backend/internal/utils/minio.go @@ -23,22 +23,23 @@ import ( "net/url" ) -func LocalizeS3Url(originalURL string) (string, error) { +// TODO: Move this method to s3 service +func LocalizeS3Url(originalURL string) (*url.URL, error) { cfg := config.GetConfig() newDomain := fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port) parsedURL, err := url.Parse(originalURL) if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) + return nil, fmt.Errorf("invalid URL: %w", err) } - newURL := &url.URL{ + newURL := url.URL{ Scheme: parsedURL.Scheme, Host: newDomain, Path: "/s3" + parsedURL.Path, RawQuery: parsedURL.RawQuery, } - return newURL.String(), nil + return &newURL, nil } diff --git a/backend/internal/validation/custom.go b/backend/internal/validation/custom.go index 32937a0..06ec89c 100644 --- a/backend/internal/validation/custom.go +++ b/backend/internal/validation/custom.go @@ -110,6 +110,20 @@ func GetCustomHandlers() []CustomValidatorHandler { panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType)) }}, + + { + FieldName: "upload_id", + Function: func(fl validator.FieldLevel) bool { + uploadType := fl.Param() + uploadID := fl.Field().String() + + pattern := fmt.Sprintf( + "^%s-([{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?)$", + uploadType, + ) + + return regexp.MustCompile(pattern).MatchString(uploadID) + }}, } diff --git a/sqlc/query.sql b/sqlc/query.sql index ffdaa49..4aefdf9 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -285,9 +285,9 @@ SET name = COALESCE($2, name), bio = COALESCE($3, bio), birthday = COALESCE($4, birthday), - avatar_url = COALESCE($5, avatar_url), - color = COALESCE($6, color), - color_grad = COALESCE($7, color_grad) + avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url), + color = COALESCE($5, color), + color_grad = COALESCE($6, color_grad) FROM users WHERE username = $1;