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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
27
backend/internal/errors/s3.go
Normal file
27
backend/internal/errors/s3.go
Normal 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")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user