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()
})
}