diff --git a/backend/docs/docs.go b/backend/docs/docs.go index a3ac550..705a8f4 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -462,11 +462,6 @@ const docTemplate = `{ }, "/upload/avatar": { "get": { - "security": [ - { - "JWT": [] - } - ], "consumes": [ "application/json" ], @@ -483,20 +478,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/models.PresignedUploadResponse" } - }, - "500": { - "description": "Internal server error" } } } }, "/upload/image": { "get": { - "security": [ - { - "JWT": [] - } - ], "consumes": [ "application/json" ], @@ -513,9 +500,6 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/models.PresignedUploadResponse" } - }, - "500": { - "description": "Internal server error" } } } diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 38c280c..7fed634 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -458,11 +458,6 @@ }, "/upload/avatar": { "get": { - "security": [ - { - "JWT": [] - } - ], "consumes": [ "application/json" ], @@ -479,20 +474,12 @@ "schema": { "$ref": "#/definitions/models.PresignedUploadResponse" } - }, - "500": { - "description": "Internal server error" } } } }, "/upload/image": { "get": { - "security": [ - { - "JWT": [] - } - ], "consumes": [ "application/json" ], @@ -509,9 +496,6 @@ "schema": { "$ref": "#/definitions/models.PresignedUploadResponse" } - }, - "500": { - "description": "Internal server error" } } } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index b1345be..9410109 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -471,10 +471,6 @@ paths: 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 @@ -489,10 +485,6 @@ paths: 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 diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go index 49292c9..6d8ccb5 100644 --- a/backend/internal/controllers/controller.go +++ b/backend/internal/controllers/controller.go @@ -28,7 +28,6 @@ import ( "path/filepath" "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" "github.com/google/uuid" "go.uber.org/zap" ) @@ -127,10 +126,9 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { validate := validation.NewValidator() if err := validate.Struct(body); err != nil { - errorList := err.(validator.ValidationErrors) c.AbortWithStatusJSON( http.StatusBadRequest, - gin.H{"error": errorList}) + gin.H{"error": err.Error()}) return nil, err } diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go index 45ee316..dd5ed2c 100644 --- a/backend/internal/controllers/profile.go +++ b/backend/internal/controllers/profile.go @@ -81,7 +81,6 @@ func NewProfileController(_log *zap.Logger, _ps services.ProfileService) Control } } -// XXX: untested // @Summary Get your profile // @Tags Profile // @Accept json @@ -100,7 +99,6 @@ func (ctrl *ProfileController) getMyProfile(c *gin.Context) { c.JSON(http.StatusOK, response) } -// XXX: untested // @Summary Get profile by username // @Tags Profile // @Accept json @@ -131,12 +129,8 @@ func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) { } c.JSON(http.StatusOK, response) - - print(cinfo.Username) - panic("Not implemented") } -// XXX: untested // @Summary Get your profile settings // @Tags Profile // @Accept json @@ -155,7 +149,6 @@ func (ctrl *ProfileController) getProfileSettings(c *gin.Context) { c.JSON(http.StatusOK, response) } -// XXX: untested // @Summary Update your profile // @Tags Profile // @Accept json @@ -169,7 +162,12 @@ func (ctrl *ProfileController) updateProfile(c *gin.Context) { return } - response, err := ctrl.ps.UpdateProfile(request.User, request.Body); if err != nil || !response { + response, err := ctrl.ps.UpdateProfile(request.User, request.Body); if err != nil { + + if errors.Is(err, errs.ErrFileNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID. Make sure file was uploaded and is not expired."}) + } + c.Status(http.StatusInternalServerError) return } @@ -177,7 +175,6 @@ func (ctrl *ProfileController) updateProfile(c *gin.Context) { c.JSON(http.StatusOK, response) } -// XXX: untested // @Summary Update your profile's settings // @Tags Profile // @Accept json diff --git a/backend/internal/controllers/s3.go b/backend/internal/controllers/s3.go index 3adee0f..fcd25c0 100644 --- a/backend/internal/controllers/s3.go +++ b/backend/internal/controllers/s3.go @@ -47,14 +47,14 @@ func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller { { HttpMethod: GET, Path: "/avatar", - Authorization: enums.UserRole, + Authorization: enums.GuestRole, Middleware: []gin.HandlerFunc{}, Function: ctrl.getAvatarUploadUrl, }, { HttpMethod: GET, Path: "/image", - Authorization: enums.UserRole, + Authorization: enums.GuestRole, Middleware: []gin.HandlerFunc{}, Function: ctrl.getImageUploadUrl, }, @@ -62,14 +62,11 @@ func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller { } } -// XXX: untested // @Summary Get presigned URL for avatar upload // @Tags Upload // @Accept json // @Produce json -// @Security JWT // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" -// @Failure 500 "Internal server error" // @Router /upload/avatar [get] func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) { url, formData, err := ctrl.s3.CreateAvatarUrl() @@ -85,14 +82,11 @@ func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) { }) } -// XXX: untested // @Summary Get presigned URL for image upload // @Tags Upload // @Accept json // @Produce json -// @Security JWT // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" -// @Failure 500 "Internal server error" // @Router /upload/image [get] func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) { url, formData, err := ctrl.s3.CreateImageUrl() diff --git a/backend/internal/controllers/setup.go b/backend/internal/controllers/setup.go index 2d3e28c..951979f 100644 --- a/backend/internal/controllers/setup.go +++ b/backend/internal/controllers/setup.go @@ -68,6 +68,7 @@ var Module = fx.Module("controllers", fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)), + fx.Annotate(NewS3Controller, fx.ResultTags(`group:"controllers"`)), ), fx.Invoke(setupControllers), ) diff --git a/backend/internal/dto/profile.go b/backend/internal/dto/profile.go index 21ec198..bba4da5 100644 --- a/backend/internal/dto/profile.go +++ b/backend/internal/dto/profile.go @@ -29,7 +29,7 @@ type ProfileDto struct { 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"` + AvatarUploadID string `json:"avatar_upload_id" validate:"omitempty,upload_id=avatar"` Birthday int64 `json:"birthday"` Color string `json:"color" validate:"color_hex"` ColorGrad string `json:"color_grad" validate:"color_hex"` diff --git a/backend/internal/errors/s3.go b/backend/internal/errors/s3.go new file mode 100644 index 0000000..9cb9300 --- /dev/null +++ b/backend/internal/errors/s3.go @@ -0,0 +1,27 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +package errors + +import ( + "errors" +) + +var ( + ErrFileNotFound = errors.New("File with this key does not exist") +) diff --git a/backend/internal/errors/smtp.go b/backend/internal/errors/smtp.go index 14a5524..21c8781 100644 --- a/backend/internal/errors/smtp.go +++ b/backend/internal/errors/smtp.go @@ -1,3 +1,20 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + package errors import ( diff --git a/backend/internal/minioClient/ginEndpoint.go b/backend/internal/minioClient/ginEndpoint.go index 39084ce..28d92ea 100644 --- a/backend/internal/minioClient/ginEndpoint.go +++ b/backend/internal/minioClient/ginEndpoint.go @@ -20,56 +20,50 @@ package minioclient import ( "easywish/config" "fmt" - "io" - "maps" "net/http" + "net/http/httputil" "net/url" + "strings" "time" "github.com/gin-gonic/gin" ) func setupGinEndpoint(router *gin.Engine) { - cfg := config.GetConfig() minioHost := fmt.Sprintf("%s:%d", cfg.MinioHost, cfg.MinioPort) + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + path := strings.TrimPrefix(req.URL.Path, "/s3") + targetURL := &url.URL{ + Scheme: "http", + Host: minioHost, + Path: path, + RawQuery: req.URL.RawQuery, + } + + req.URL = targetURL + req.Host = minioHost + req.Header.Set("Host", minioHost) + + req.Header.Del("X-Forwarded-For") + req.Header.Del("X-Forwarded-Host") + req.Header.Del("X-Forwarded-Proto") + }, + Transport: &http.Transport{ + ResponseHeaderTimeout: time.Duration(cfg.MinioTimeout), + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "Failed to forward request"}`)) + fmt.Printf("Proxy error: %v\n", err) + }, + } + s3group := router.Group("/s3") s3group.Any("/*path", func(c *gin.Context) { - path := c.Param("path") - minioURL := &url.URL{ - Scheme: "http", - Host: minioHost, - Path: path, - RawQuery: c.Request.URL.RawQuery, - } - req, err := http.NewRequest(c.Request.Method, minioURL.String(), c.Request.Body); if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) - return - } - req = req.WithContext(c.Request.Context()) - maps.Copy(req.Header, c.Request.Header) - req.Header.Set("Host", minioHost) - client := &http.Client{ - Timeout: time.Duration(cfg.MinioTimeout), - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Do(req); if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to forward request"}) - return - } - defer resp.Body.Close() - for key, values := range resp.Header { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } - c.Status(resp.StatusCode) - _, err = io.Copy(c.Writer, resp.Body); if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy response body"}) - return - } + proxy.ServeHTTP(c.Writer, c.Request) }) } diff --git a/backend/internal/services/profile.go b/backend/internal/services/profile.go index 1c4fdf8..c6698ab 100644 --- a/backend/internal/services/profile.go +++ b/backend/internal/services/profile.go @@ -50,11 +50,10 @@ type profileServiceImpl struct { s3 S3Service } -func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client) ProfileService { - return &profileServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, minio: _minio} +func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client, _s3 S3Service) ProfileService { + return &profileServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, minio: _minio, s3: _s3} } -// XXX: untested func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) { db := database.NewDbHelper(p.dbctx); @@ -65,13 +64,12 @@ func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto return nil, errs.ErrServerError } - var profileDto dto.ProfileDto + profileDto := &dto.ProfileDto{} mapspecial.MapProfileDto(profile, profileDto) - return &profileDto, nil + return profileDto, nil } -// XXX: untested // TODO: Profile privacy settings checks func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { @@ -94,13 +92,12 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username return nil, errs.ErrServerError } - var profileDto dto.ProfileDto + profileDto := &dto.ProfileDto{} mapspecial.MapProfileDto(profile, profileDto) - return &profileDto, nil + return profileDto, nil } -// XXX: unstested func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) { db := database.NewDbHelper(p.dbctx); @@ -111,10 +108,10 @@ func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.Prof return nil, errs.ErrServerError } - var profileSettingsDto dto.ProfileSettingsDto + profileSettingsDto := &dto.ProfileSettingsDto{} automapper.Map(profileSettings, profileSettingsDto) - return &profileSettingsDto, nil + return profileSettingsDto, nil } // XXX: no validation for timestamps' allowed ranges @@ -133,10 +130,15 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto. } var avatarUrl *string - if newProfile.AvatarUploadID != nil { - key, err := p.s3.SaveUpload(*newProfile.AvatarUploadID, "avatars"); if err != nil { + if newProfile.AvatarUploadID != "" { + key, err := p.s3.SaveUpload(newProfile.AvatarUploadID, "avatars"); if err != nil { + + if errors.Is(err, errs.ErrFileNotFound) { + return false, err + } + p.log.Error("Failed to save avatar", - zap.String("upload_id", *newProfile.AvatarUploadID), + zap.String("upload_id", newProfile.AvatarUploadID), zap.Error(err)) return false, errs.ErrServerError } @@ -151,6 +153,8 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto. Bio: newProfile.Bio, Birthday: birthdayTimestamp, AvatarUrl: avatarUrl, + Color: newProfile.Color, + ColorGrad: newProfile.ColorGrad, }); if err != nil { p.log.Error( "Failed to update user profile", @@ -169,7 +173,6 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto. return true, nil } -// XXX: untested func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { p.log.Error( diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index d94449e..145c07f 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -21,6 +21,7 @@ import ( "context" "easywish/config" minioclient "easywish/internal/minioClient" + errs "easywish/internal/errors" "easywish/internal/utils" "fmt" "net/url" @@ -126,8 +127,14 @@ func (s *s3ServiceImpl) SaveUpload(uploadID string, bucketAlias string) (*string sourceBucket := minioclient.Buckets["uploads"] bucket := minioclient.Buckets[bucketAlias] newObjectKey := uuid.New().String() - - _, err := s.minio.CopyObject(context.Background(), minio.CopyDestOptions{ + + _, err := s.minio.StatObject(context.Background(), sourceBucket, uploadID, minio.StatObjectOptions{}) + if err != nil { + if minio.ToErrorResponse(err).Code == minio.NoSuchKey { + return nil, errs.ErrFileNotFound + } + } + _, err = s.minio.CopyObject(context.Background(), minio.CopyDestOptions{ Bucket: bucket, Object: newObjectKey, }, minio.CopySrcOptions{ diff --git a/backend/internal/utils/mapSpecial/profileDto.go b/backend/internal/utils/mapSpecial/profileDto.go index 4d7d486..eb141f0 100644 --- a/backend/internal/utils/mapSpecial/profileDto.go +++ b/backend/internal/utils/mapSpecial/profileDto.go @@ -24,8 +24,11 @@ import ( "github.com/rafiulgits/go-automapper" ) -func MapProfileDto(dbModel database.Profile, dtoModel dto.ProfileDto) { - automapper.Map(dbModel, &dbModel, func(src *database.Profile, dst *dto.ProfileDto) { - dst.Birthday = int64(dbModel.Birthday.Time.UnixMilli()) +func MapProfileDto(dbModel database.Profile, dtoModel *dto.ProfileDto) { + if dtoModel == nil { + dtoModel = &dto.ProfileDto{} + } + automapper.Map(&dbModel, dtoModel, func(src *database.Profile, dst *dto.ProfileDto) { + dst.Birthday = src.Birthday.Time.UnixMilli() }) }