feat: remove authentication requirement for avatar and image upload endpoints;

fix: remove 500 error responses from upload endpoints;
fix: return validation error strings instead of error lists;
fix: handle invalid avatar upload IDs with 400 Bad Request response;
fix: add missing S3Controller to controller initialization;
fix: change avatar_upload_id to string type and update validation rules;
chore: add license header to smtp.go;
refactor: replace manual proxy implementation with httputil.ReverseProxy;
fix: inject S3Service dependency into ProfileService;
fix: set color and color_grad fields during profile update;
fix: correct DTO mapping for profile and settings;
fix: check object existence before copying in SaveUpload;
fix: adjust profile DTO mapping function for proper pointer handling
This commit is contained in:
2025-08-02 03:47:56 +03:00
parent 669349e020
commit 5ed75c350a
14 changed files with 120 additions and 119 deletions

View File

@@ -462,11 +462,6 @@ const docTemplate = `{
}, },
"/upload/avatar": { "/upload/avatar": {
"get": { "get": {
"security": [
{
"JWT": []
}
],
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -483,20 +478,12 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/models.PresignedUploadResponse" "$ref": "#/definitions/models.PresignedUploadResponse"
} }
},
"500": {
"description": "Internal server error"
} }
} }
} }
}, },
"/upload/image": { "/upload/image": {
"get": { "get": {
"security": [
{
"JWT": []
}
],
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -513,9 +500,6 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/models.PresignedUploadResponse" "$ref": "#/definitions/models.PresignedUploadResponse"
} }
},
"500": {
"description": "Internal server error"
} }
} }
} }

View File

@@ -458,11 +458,6 @@
}, },
"/upload/avatar": { "/upload/avatar": {
"get": { "get": {
"security": [
{
"JWT": []
}
],
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -479,20 +474,12 @@
"schema": { "schema": {
"$ref": "#/definitions/models.PresignedUploadResponse" "$ref": "#/definitions/models.PresignedUploadResponse"
} }
},
"500": {
"description": "Internal server error"
} }
} }
} }
}, },
"/upload/image": { "/upload/image": {
"get": { "get": {
"security": [
{
"JWT": []
}
],
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -509,9 +496,6 @@
"schema": { "schema": {
"$ref": "#/definitions/models.PresignedUploadResponse" "$ref": "#/definitions/models.PresignedUploadResponse"
} }
},
"500": {
"description": "Internal server error"
} }
} }
} }

View File

@@ -471,10 +471,6 @@ paths:
description: Presigned URL and form data description: Presigned URL and form data
schema: schema:
$ref: '#/definitions/models.PresignedUploadResponse' $ref: '#/definitions/models.PresignedUploadResponse'
"500":
description: Internal server error
security:
- JWT: []
summary: Get presigned URL for avatar upload summary: Get presigned URL for avatar upload
tags: tags:
- Upload - Upload
@@ -489,10 +485,6 @@ paths:
description: Presigned URL and form data description: Presigned URL and form data
schema: schema:
$ref: '#/definitions/models.PresignedUploadResponse' $ref: '#/definitions/models.PresignedUploadResponse'
"500":
description: Internal server error
security:
- JWT: []
summary: Get presigned URL for image upload summary: Get presigned URL for image upload
tags: tags:
- Upload - Upload

View File

@@ -28,7 +28,6 @@ import (
"path/filepath" "path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -127,10 +126,9 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {
validate := validation.NewValidator() validate := validation.NewValidator()
if err := validate.Struct(body); err != nil { if err := validate.Struct(body); err != nil {
errorList := err.(validator.ValidationErrors)
c.AbortWithStatusJSON( c.AbortWithStatusJSON(
http.StatusBadRequest, http.StatusBadRequest,
gin.H{"error": errorList}) gin.H{"error": err.Error()})
return nil, err return nil, err
} }

View File

@@ -81,7 +81,6 @@ func NewProfileController(_log *zap.Logger, _ps services.ProfileService) Control
} }
} }
// XXX: untested
// @Summary Get your profile // @Summary Get your profile
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
@@ -100,7 +99,6 @@ func (ctrl *ProfileController) getMyProfile(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// XXX: untested
// @Summary Get profile by username // @Summary Get profile by username
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
@@ -131,12 +129,8 @@ func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
print(cinfo.Username)
panic("Not implemented")
} }
// XXX: untested
// @Summary Get your profile settings // @Summary Get your profile settings
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
@@ -155,7 +149,6 @@ func (ctrl *ProfileController) getProfileSettings(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// XXX: untested
// @Summary Update your profile // @Summary Update your profile
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
@@ -169,7 +162,12 @@ func (ctrl *ProfileController) updateProfile(c *gin.Context) {
return 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) c.Status(http.StatusInternalServerError)
return return
} }
@@ -177,7 +175,6 @@ func (ctrl *ProfileController) updateProfile(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// XXX: untested
// @Summary Update your profile's settings // @Summary Update your profile's settings
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json

View File

@@ -47,14 +47,14 @@ func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller {
{ {
HttpMethod: GET, HttpMethod: GET,
Path: "/avatar", Path: "/avatar",
Authorization: enums.UserRole, Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{}, Middleware: []gin.HandlerFunc{},
Function: ctrl.getAvatarUploadUrl, Function: ctrl.getAvatarUploadUrl,
}, },
{ {
HttpMethod: GET, HttpMethod: GET,
Path: "/image", Path: "/image",
Authorization: enums.UserRole, Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{}, Middleware: []gin.HandlerFunc{},
Function: ctrl.getImageUploadUrl, 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 // @Summary Get presigned URL for avatar upload
// @Tags Upload // @Tags Upload
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Failure 500 "Internal server error"
// @Router /upload/avatar [get] // @Router /upload/avatar [get]
func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) { func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateAvatarUrl() 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 // @Summary Get presigned URL for image upload
// @Tags Upload // @Tags Upload
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data" // @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Failure 500 "Internal server error"
// @Router /upload/image [get] // @Router /upload/image [get]
func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) { func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateImageUrl() url, formData, err := ctrl.s3.CreateImageUrl()

View File

@@ -68,6 +68,7 @@ var Module = fx.Module("controllers",
fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewS3Controller, fx.ResultTags(`group:"controllers"`)),
), ),
fx.Invoke(setupControllers), fx.Invoke(setupControllers),
) )

View File

@@ -29,7 +29,7 @@ type ProfileDto struct {
type NewProfileDto struct { type NewProfileDto struct {
Name string `json:"name" binding:"required" validate:"name"` Name string `json:"name" binding:"required" validate:"name"`
Bio string `json:"bio" validate:"bio"` 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"` Birthday int64 `json:"birthday"`
Color string `json:"color" validate:"color_hex"` Color string `json:"color" validate:"color_hex"`
ColorGrad string `json:"color_grad" validate:"color_hex"` ColorGrad string `json:"color_grad" validate:"color_hex"`

View File

@@ -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 <https://www.gnu.org/licenses/>.
package errors
import (
"errors"
)
var (
ErrFileNotFound = errors.New("File with this key does not exist")
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
package errors package errors
import ( import (

View File

@@ -20,56 +20,50 @@ package minioclient
import ( import (
"easywish/config" "easywish/config"
"fmt" "fmt"
"io"
"maps"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func setupGinEndpoint(router *gin.Engine) { func setupGinEndpoint(router *gin.Engine) {
cfg := config.GetConfig() cfg := config.GetConfig()
minioHost := fmt.Sprintf("%s:%d", cfg.MinioHost, cfg.MinioPort) 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 := router.Group("/s3")
s3group.Any("/*path", func(c *gin.Context) { s3group.Any("/*path", func(c *gin.Context) {
path := c.Param("path") proxy.ServeHTTP(c.Writer, c.Request)
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
}
}) })
} }

View File

@@ -50,11 +50,10 @@ type profileServiceImpl struct {
s3 S3Service s3 S3Service
} }
func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client) ProfileService { 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} return &profileServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, minio: _minio, s3: _s3}
} }
// XXX: untested
func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) { func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) {
db := database.NewDbHelper(p.dbctx); db := database.NewDbHelper(p.dbctx);
@@ -65,13 +64,12 @@ func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
var profileDto dto.ProfileDto profileDto := &dto.ProfileDto{}
mapspecial.MapProfileDto(profile, profileDto) mapspecial.MapProfileDto(profile, profileDto)
return &profileDto, nil return profileDto, nil
} }
// XXX: untested
// TODO: Profile privacy settings checks // TODO: Profile privacy settings checks
func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) { func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { 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 return nil, errs.ErrServerError
} }
var profileDto dto.ProfileDto profileDto := &dto.ProfileDto{}
mapspecial.MapProfileDto(profile, profileDto) mapspecial.MapProfileDto(profile, profileDto)
return &profileDto, nil return profileDto, nil
} }
// XXX: unstested
func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) { func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) {
db := database.NewDbHelper(p.dbctx); db := database.NewDbHelper(p.dbctx);
@@ -111,10 +108,10 @@ func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.Prof
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
var profileSettingsDto dto.ProfileSettingsDto profileSettingsDto := &dto.ProfileSettingsDto{}
automapper.Map(profileSettings, profileSettingsDto) automapper.Map(profileSettings, profileSettingsDto)
return &profileSettingsDto, nil return profileSettingsDto, nil
} }
// XXX: no validation for timestamps' allowed ranges // XXX: no validation for timestamps' allowed ranges
@@ -133,10 +130,15 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.
} }
var avatarUrl *string var avatarUrl *string
if newProfile.AvatarUploadID != nil { if newProfile.AvatarUploadID != "" {
key, err := p.s3.SaveUpload(*newProfile.AvatarUploadID, "avatars"); if err != nil { 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", p.log.Error("Failed to save avatar",
zap.String("upload_id", *newProfile.AvatarUploadID), zap.String("upload_id", newProfile.AvatarUploadID),
zap.Error(err)) zap.Error(err))
return false, errs.ErrServerError return false, errs.ErrServerError
} }
@@ -151,6 +153,8 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.
Bio: newProfile.Bio, Bio: newProfile.Bio,
Birthday: birthdayTimestamp, Birthday: birthdayTimestamp,
AvatarUrl: avatarUrl, AvatarUrl: avatarUrl,
Color: newProfile.Color,
ColorGrad: newProfile.ColorGrad,
}); if err != nil { }); if err != nil {
p.log.Error( p.log.Error(
"Failed to update user profile", "Failed to update user profile",
@@ -169,7 +173,6 @@ func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.
return true, nil return true, nil
} }
// XXX: untested
func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) { func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error( p.log.Error(

View File

@@ -21,6 +21,7 @@ import (
"context" "context"
"easywish/config" "easywish/config"
minioclient "easywish/internal/minioClient" minioclient "easywish/internal/minioClient"
errs "easywish/internal/errors"
"easywish/internal/utils" "easywish/internal/utils"
"fmt" "fmt"
"net/url" "net/url"
@@ -127,7 +128,13 @@ func (s *s3ServiceImpl) SaveUpload(uploadID string, bucketAlias string) (*string
bucket := minioclient.Buckets[bucketAlias] bucket := minioclient.Buckets[bucketAlias]
newObjectKey := uuid.New().String() 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, Bucket: bucket,
Object: newObjectKey, Object: newObjectKey,
}, minio.CopySrcOptions{ }, minio.CopySrcOptions{

View File

@@ -24,8 +24,11 @@ import (
"github.com/rafiulgits/go-automapper" "github.com/rafiulgits/go-automapper"
) )
func MapProfileDto(dbModel database.Profile, dtoModel dto.ProfileDto) { func MapProfileDto(dbModel database.Profile, dtoModel *dto.ProfileDto) {
automapper.Map(dbModel, &dbModel, func(src *database.Profile, dst *dto.ProfileDto) { if dtoModel == nil {
dst.Birthday = int64(dbModel.Birthday.Time.UnixMilli()) dtoModel = &dto.ProfileDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.Profile, dst *dto.ProfileDto) {
dst.Birthday = src.Birthday.Time.UnixMilli()
}) })
} }