Skip to content

Commit 48d2af5

Browse files
bramhaagGusted
authored andcommitted
fix: skip repo avatar upload when no file is selected (#11335)
Submitting the repo avatar form without selecting a file shows a raw Go error: `Avatar.Open: open : no such file or directory.`. The existing `nil` check does not prevent this from happening. The user avatar handler already guards against this same problem with [`form.Avatar != nil && form.Avatar.Filename != ""`](https://codeberg.org/forgejo/forgejo/src/commit/e1cecbd276841a5762ad034a920be8c2732e295f/routers/web/user/setting/profile.go#L141), I've done the same for the repo avatar handler. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11335 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Bram Hagens <bram@bramh.me> Co-committed-by: Bram Hagens <bram@bramh.me>
1 parent 8d330dc commit 48d2af5

4 files changed

Lines changed: 150 additions & 57 deletions

File tree

routers/web/repo/setting/avatar.go

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,8 @@
44
package setting
55

66
import (
7-
"errors"
87
"fmt"
9-
"io"
108

11-
"forgejo.org/modules/log"
12-
"forgejo.org/modules/setting"
13-
"forgejo.org/modules/typesniffer"
149
"forgejo.org/modules/web"
1510
"forgejo.org/services/context"
1611
"forgejo.org/services/forms"
@@ -19,37 +14,14 @@ import (
1914

2015
// UpdateAvatarSetting update repo's avatar
2116
func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
22-
ctxRepo := ctx.Repo.Repository
23-
24-
if form.Avatar == nil {
25-
// No avatar is uploaded and we not removing it here.
26-
// No random avatar generated here.
27-
// Just exit, no action.
28-
if ctxRepo.CustomAvatarRelativePath() == "" {
29-
log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID)
30-
}
31-
return nil
32-
}
33-
34-
r, err := form.Avatar.Open()
35-
if err != nil {
36-
return fmt.Errorf("Avatar.Open: %w", err)
37-
}
38-
defer r.Close()
39-
40-
if form.Avatar.Size > setting.Avatar.MaxFileSize {
41-
return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
42-
}
43-
44-
data, err := io.ReadAll(r)
17+
data, err := forms.ReadAvatar(form.Avatar, ctx.Locale)
4518
if err != nil {
46-
return fmt.Errorf("io.ReadAll: %w", err)
19+
return err
4720
}
48-
st := typesniffer.DetectContentType(data, "")
49-
if !st.IsImage() || st.IsSvgImage() {
50-
return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
21+
if data == nil {
22+
return nil
5123
}
52-
if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil {
24+
if err := repo_service.UploadAvatar(ctx, ctx.Repo.Repository, data); err != nil {
5325
return fmt.Errorf("UploadAvatar: %w", err)
5426
}
5527
return nil

routers/web/user/setting/profile.go

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
package setting
66

77
import (
8-
"errors"
98
"fmt"
10-
"io"
119
"math/big"
1210
"net/http"
1311
"os"
@@ -25,7 +23,6 @@ import (
2523
"forgejo.org/modules/optional"
2624
"forgejo.org/modules/setting"
2725
"forgejo.org/modules/translation"
28-
"forgejo.org/modules/typesniffer"
2926
"forgejo.org/modules/util"
3027
"forgejo.org/modules/web"
3128
"forgejo.org/modules/web/middleware"
@@ -138,27 +135,12 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *
138135
ctxUser.AvatarEmail = form.Gravatar
139136
}
140137

141-
if form.Avatar != nil && form.Avatar.Filename != "" {
142-
fr, err := form.Avatar.Open()
143-
if err != nil {
144-
return fmt.Errorf("Avatar.Open: %w", err)
145-
}
146-
defer fr.Close()
147-
148-
if form.Avatar.Size > setting.Avatar.MaxFileSize {
149-
return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
150-
}
151-
152-
data, err := io.ReadAll(fr)
153-
if err != nil {
154-
return fmt.Errorf("io.ReadAll: %w", err)
155-
}
156-
157-
st := typesniffer.DetectContentType(data, "")
158-
if !st.IsImage() || st.IsSvgImage() {
159-
return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
160-
}
161-
if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil {
138+
data, err := forms.ReadAvatar(form.Avatar, ctx.Locale)
139+
if err != nil {
140+
return err
141+
}
142+
if data != nil {
143+
if err := user_service.UploadAvatar(ctx, ctxUser, data); err != nil {
162144
return fmt.Errorf("UploadAvatar: %w", err)
163145
}
164146
} else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" {

services/forms/avatar.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2018 The Gitea Authors. All rights reserved.
2+
// Copyright 2026 The Forgejo Authors. All rights reserved.
3+
// SPDX-License-Identifier: MIT
4+
5+
package forms
6+
7+
import (
8+
"errors"
9+
"fmt"
10+
"io"
11+
"mime/multipart"
12+
13+
"forgejo.org/modules/setting"
14+
"forgejo.org/modules/translation"
15+
"forgejo.org/modules/typesniffer"
16+
)
17+
18+
// ReadAvatar reads and validates an avatar from a multipart file header.
19+
func ReadAvatar(header *multipart.FileHeader, locale translation.Locale) ([]byte, error) {
20+
if header == nil || header.Filename == "" {
21+
return nil, nil
22+
}
23+
24+
r, err := header.Open()
25+
if err != nil {
26+
return nil, fmt.Errorf("Avatar.Open: %w", err)
27+
}
28+
defer r.Close()
29+
30+
if header.Size > setting.Avatar.MaxFileSize {
31+
return nil, errors.New(locale.TrString("settings.uploaded_avatar_is_too_big", header.Size/1024, setting.Avatar.MaxFileSize/1024))
32+
}
33+
34+
data, err := io.ReadAll(r)
35+
if err != nil {
36+
return nil, fmt.Errorf("io.ReadAll: %w", err)
37+
}
38+
39+
st := typesniffer.DetectContentType(data, "")
40+
if !st.IsImage() || st.IsSvgImage() {
41+
return nil, errors.New(locale.TrString("settings.uploaded_avatar_not_a_image"))
42+
}
43+
44+
return data, nil
45+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package integration
5+
6+
import (
7+
"bytes"
8+
"image/png"
9+
"io"
10+
"mime/multipart"
11+
"net/http"
12+
"testing"
13+
14+
repo_model "forgejo.org/models/repo"
15+
"forgejo.org/models/unittest"
16+
user_model "forgejo.org/models/user"
17+
"forgejo.org/modules/avatar"
18+
app_context "forgejo.org/services/context"
19+
"forgejo.org/tests"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestRepoAvatar(t *testing.T) {
26+
defer tests.PrepareTestEnv(t)()
27+
28+
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
29+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
30+
31+
session := loginUser(t, user2.Name)
32+
avatarURL := "/" + repo.OwnerName + "/" + repo.Name + "/settings/avatar"
33+
34+
t.Run("valid avatar", func(t *testing.T) {
35+
defer tests.PrintCurrentTest(t)()
36+
37+
img, err := avatar.RandomImage([]byte("seed"))
38+
require.NoError(t, err)
39+
40+
imgData := &bytes.Buffer{}
41+
require.NoError(t, png.Encode(imgData, img))
42+
43+
body := &bytes.Buffer{}
44+
writer := multipart.NewWriter(body)
45+
require.NoError(t, writer.WriteField("source", "local"))
46+
part, err := writer.CreateFormFile("avatar", "avatar.png")
47+
require.NoError(t, err)
48+
_, err = io.Copy(part, imgData)
49+
require.NoError(t, err)
50+
require.NoError(t, writer.Close())
51+
52+
req := NewRequestWithBody(t, "POST", avatarURL, body)
53+
req.Header.Add("Content-Type", writer.FormDataContentType())
54+
session.MakeRequest(t, req, http.StatusSeeOther)
55+
56+
flashCookie := session.GetCookie(app_context.CookieNameFlash)
57+
require.NotNil(t, flashCookie)
58+
assert.Contains(t, flashCookie.Value, "success")
59+
60+
// Verify avatar was actually set
61+
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
62+
assert.NotEmpty(t, repo.CustomAvatarRelativePath())
63+
64+
// Verify avatar is accessible
65+
req = NewRequest(t, "GET", "/repo-avatars/"+repo.Avatar)
66+
_ = session.MakeRequest(t, req, http.StatusOK)
67+
68+
// Clean up
69+
req = NewRequest(t, "POST", avatarURL+"/delete")
70+
session.MakeRequest(t, req, http.StatusOK)
71+
})
72+
73+
t.Run("no file selected", func(t *testing.T) {
74+
defer tests.PrintCurrentTest(t)()
75+
76+
// Simulate what a browser sends when user did not select a file
77+
body := &bytes.Buffer{}
78+
writer := multipart.NewWriter(body)
79+
require.NoError(t, writer.WriteField("source", "local"))
80+
_, err := writer.CreateFormFile("avatar", "")
81+
require.NoError(t, err)
82+
require.NoError(t, writer.Close())
83+
84+
req := NewRequestWithBody(t, "POST", avatarURL, body)
85+
req.Header.Add("Content-Type", writer.FormDataContentType())
86+
session.MakeRequest(t, req, http.StatusSeeOther)
87+
88+
// Should silently skip
89+
flashCookie := session.GetCookie(app_context.CookieNameFlash)
90+
if flashCookie != nil {
91+
assert.NotContains(t, flashCookie.Value, "error")
92+
}
93+
})
94+
}

0 commit comments

Comments
 (0)