Skip to content

Commit 64f697c

Browse files
committed
refactor(avatar): share avatar-upload logic between v1 and v2 handlers
1 parent 236c838 commit 64f697c

3 files changed

Lines changed: 51 additions & 44 deletions

File tree

pkg/modules/avatar/avatar.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
package avatar
1818

1919
import (
20+
"errors"
21+
"io"
22+
"strings"
23+
2024
"code.vikunja.io/api/pkg/log"
2125
"code.vikunja.io/api/pkg/modules/avatar/botmarble"
2226
"code.vikunja.io/api/pkg/modules/avatar/empty"
@@ -27,8 +31,15 @@ import (
2731
"code.vikunja.io/api/pkg/modules/avatar/openid"
2832
"code.vikunja.io/api/pkg/modules/avatar/upload"
2933
"code.vikunja.io/api/pkg/user"
34+
35+
"github.com/gabriel-vasile/mimetype"
36+
"xorm.io/xorm"
3037
)
3138

39+
// ErrNotAnImage is returned by StoreUploadedAvatar when the uploaded file is not an image.
40+
// Callers should map this to a 400 Bad Request response.
41+
var ErrNotAnImage = errors.New("uploaded file is no image")
42+
3243
// Provider defines the avatar provider interface
3344
type Provider interface {
3445
// GetAvatar is the method used to get an actual avatar for a user
@@ -82,3 +93,32 @@ func GetProvider(u *user.User) Provider {
8293
return &empty.Provider{}
8394
}
8495
}
96+
97+
// StoreUploadedAvatar validates that src is an image, switches the user's avatar
98+
// provider to "upload", stores the image as the user's avatar and flushes all
99+
// cached avatars for the user. It returns ErrNotAnImage if src is not an image.
100+
//
101+
// This is the shared core used by both the v1 and v2 avatar upload handlers; the
102+
// callers are responsible for the transport-specific parts (reading the uploaded
103+
// file and writing the response).
104+
func StoreUploadedAvatar(s *xorm.Session, u *user.User, src io.ReadSeeker) error {
105+
mime, err := mimetype.DetectReader(src)
106+
if err != nil {
107+
return err
108+
}
109+
if !strings.HasPrefix(mime.String(), "image") {
110+
return ErrNotAnImage
111+
}
112+
if _, err := src.Seek(0, io.SeekStart); err != nil {
113+
return err
114+
}
115+
116+
u.AvatarProvider = "upload"
117+
if err := upload.StoreAvatarFile(s, u, src); err != nil {
118+
return err
119+
}
120+
121+
FlushAllCaches(u)
122+
123+
return nil
124+
}

pkg/routes/api/v1/avatar.go

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,12 @@ import (
2424
"code.vikunja.io/api/pkg/modules/avatar"
2525
"code.vikunja.io/api/pkg/modules/avatar/botmarble"
2626
"code.vikunja.io/api/pkg/modules/avatar/empty"
27-
"code.vikunja.io/api/pkg/modules/avatar/upload"
2827
"code.vikunja.io/api/pkg/user"
2928

30-
"io"
29+
"errors"
3130
"net/http"
3231
"strconv"
33-
"strings"
3432

35-
"github.com/gabriel-vasile/mimetype"
3633
"github.com/labstack/echo/v5"
3734
)
3835

@@ -137,21 +134,11 @@ func UploadAvatar(c *echo.Context) (err error) {
137134
}
138135
defer src.Close()
139136

140-
// Validate we're dealing with an image
141-
mime, err := mimetype.DetectReader(src)
142-
if err != nil {
143-
_ = s.Rollback()
144-
return err
145-
}
146-
if !strings.HasPrefix(mime.String(), "image") {
147-
return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
148-
}
149-
_, _ = src.Seek(0, io.SeekStart)
150-
151-
u.AvatarProvider = "upload"
152-
err = upload.StoreAvatarFile(s, u, src)
153-
if err != nil {
137+
if err := avatar.StoreUploadedAvatar(s, u, src); err != nil {
154138
_ = s.Rollback()
139+
if errors.Is(err, avatar.ErrNotAnImage) {
140+
return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."})
141+
}
155142
return err
156143
}
157144

@@ -160,7 +147,5 @@ func UploadAvatar(c *echo.Context) (err error) {
160147
return err
161148
}
162149

163-
avatar.FlushAllCaches(u)
164-
165150
return c.JSON(http.StatusOK, models.Message{Message: "Avatar was uploaded successfully."})
166151
}

pkg/routes/api/v2/avatar_upload.go

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,20 @@ package apiv2
1818

1919
import (
2020
"context"
21-
"io"
21+
"errors"
2222
"net/http"
23-
"strings"
2423

2524
"code.vikunja.io/api/pkg/config"
2625
"code.vikunja.io/api/pkg/db"
2726
"code.vikunja.io/api/pkg/models"
2827
"code.vikunja.io/api/pkg/modules/avatar"
29-
"code.vikunja.io/api/pkg/modules/avatar/upload"
3028
"code.vikunja.io/api/pkg/user"
3129

3230
"github.com/danielgtaylor/huma/v2"
33-
"github.com/gabriel-vasile/mimetype"
3431
)
3532

3633
type avatarUploadInput struct {
37-
// Broad allow-list because Huma's MimeTypeValidator rejects the part pre-handler; octet-stream covers programmatic clients. The real gate is mimetype.DetectReader in the handler.
34+
// Broad allow-list because Huma's MimeTypeValidator rejects the part pre-handler; octet-stream covers programmatic clients. The real gate is the byte-level image check in avatar.StoreUploadedAvatar.
3835
RawBody huma.MultipartFormFiles[struct {
3936
Avatar huma.FormFile `form:"avatar" contentType:"image/png,image/jpeg,image/gif,image/webp,image/svg+xml,application/octet-stream" required:"true" doc:"The avatar image to upload. Must be an image; it is resized server-side and re-encoded as PNG."`
4037
}]
@@ -86,24 +83,11 @@ func avatarUpload(ctx context.Context, in *avatarUploadInput) (*avatarUploadBody
8683
src := in.RawBody.Data().Avatar
8784
defer func() { _ = src.Close() }()
8885

89-
// Byte-level image check, same allow-list as v1's UploadAvatar.
90-
mime, err := mimetype.DetectReader(src)
91-
if err != nil {
92-
_ = s.Rollback()
93-
return nil, translateDomainError(err)
94-
}
95-
if !strings.HasPrefix(mime.String(), "image") {
96-
_ = s.Rollback()
97-
return nil, huma.Error400BadRequest("Uploaded file is no image.")
98-
}
99-
if _, err := src.Seek(0, io.SeekStart); err != nil {
100-
_ = s.Rollback()
101-
return nil, translateDomainError(err)
102-
}
103-
104-
u.AvatarProvider = "upload"
105-
if err := upload.StoreAvatarFile(s, u, src); err != nil {
86+
if err := avatar.StoreUploadedAvatar(s, u, src); err != nil {
10687
_ = s.Rollback()
88+
if errors.Is(err, avatar.ErrNotAnImage) {
89+
return nil, huma.Error400BadRequest("Uploaded file is no image.")
90+
}
10791
return nil, translateDomainError(err)
10892
}
10993

@@ -112,7 +96,5 @@ func avatarUpload(ctx context.Context, in *avatarUploadInput) (*avatarUploadBody
11296
return nil, translateDomainError(err)
11397
}
11498

115-
avatar.FlushAllCaches(u)
116-
11799
return &avatarUploadBody{Body: &models.Message{Message: "Avatar was uploaded successfully."}}, nil
118100
}

0 commit comments

Comments
 (0)