tenseleyflow/shithub / d09fe8a

Browse files

S10: avatar upload + remove handlers

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d09fe8a94475f4dd76ad9d64dea01d721dd7e8ea
Parents
c090746
Tree
320b7e0

2 changed files

StatusFile+-
A internal/web/handlers/auth/avatar.go 141 0
A internal/web/handlers/auth/avatar_test.go 124 0
internal/web/handlers/auth/avatar.goadded
@@ -0,0 +1,141 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"bytes"
7
+	"errors"
8
+	"fmt"
9
+	"net/http"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/avatars"
14
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
15
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+// avatarUploadCap is the multipart-parse cap. Slightly larger than the
20
+// per-image cap inside `avatars.Process` to leave room for the form
21
+// envelope without rejecting valid uploads.
22
+const avatarUploadCap = avatars.MaxUploadBytes + 64*1024
23
+
24
+// settingsAvatarUpload handles POST /settings/profile/avatar.
25
+//
26
+// On success: writes 3 PNG variants to the object store, points the
27
+// user's avatar_object_key at the largest variant, and redirects back
28
+// to /settings/profile with a flash on the next render.
29
+func (h *Handlers) settingsAvatarUpload(w http.ResponseWriter, r *http.Request) {
30
+	if h.d.ObjectStore == nil {
31
+		h.d.Render.HTTPError(w, r, http.StatusServiceUnavailable, "avatar storage is not configured")
32
+		return
33
+	}
34
+	// Hard cap on the request body BEFORE multipart parsing so a large
35
+	// upload can't soak memory even via the chunked-encoding path.
36
+	r.Body = http.MaxBytesReader(w, r.Body, avatarUploadCap)
37
+	//nolint:gosec // G120 false positive: avatarUploadCap is a constant bound, and MaxBytesReader above is the real cap.
38
+	if err := r.ParseMultipartForm(avatarUploadCap); err != nil {
39
+		h.renderAvatarError(w, r, "Could not parse upload (file too large?).")
40
+		return
41
+	}
42
+	defer func() {
43
+		if r.MultipartForm != nil {
44
+			_ = r.MultipartForm.RemoveAll()
45
+		}
46
+	}()
47
+
48
+	file, _, err := r.FormFile("avatar")
49
+	if err != nil {
50
+		h.renderAvatarError(w, r, "Choose a file to upload.")
51
+		return
52
+	}
53
+	defer func() { _ = file.Close() }()
54
+
55
+	variants, hash, err := avatars.Process(file)
56
+	if err != nil {
57
+		h.renderAvatarError(w, r, friendlyAvatarError(err))
58
+		return
59
+	}
60
+
61
+	user := middleware.CurrentUserFromContext(r.Context())
62
+	prefix := fmt.Sprintf("avatars/%d/%s", user.ID, hash)
63
+	// Largest variant lives at <prefix>.png and is what the public
64
+	// avatar route serves.
65
+	largestKey := prefix + ".png"
66
+	for _, v := range variants {
67
+		key := fmt.Sprintf("%s-%d.png", prefix, v.Size)
68
+		if v.Size == variants[0].Size {
69
+			key = largestKey
70
+		}
71
+		if _, err := h.d.ObjectStore.Put(r.Context(), key,
72
+			bytes.NewReader(v.Data),
73
+			storage.PutOpts{ContentType: "image/png", ContentLength: int64(len(v.Data))},
74
+		); err != nil {
75
+			h.d.Logger.ErrorContext(r.Context(), "avatar: put", "error", err, "key", key)
76
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
77
+			return
78
+		}
79
+	}
80
+
81
+	if err := h.q.UpdateUserAvatarKey(r.Context(), h.d.Pool, usersdb.UpdateUserAvatarKeyParams{
82
+		ID:              user.ID,
83
+		AvatarObjectKey: pgtype.Text{String: largestKey, Valid: true},
84
+	}); err != nil {
85
+		h.d.Logger.ErrorContext(r.Context(), "avatar: db update", "error", err)
86
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
87
+		return
88
+	}
89
+
90
+	http.Redirect(w, r, "/settings/profile", http.StatusSeeOther)
91
+}
92
+
93
+// settingsAvatarRemove handles POST /settings/profile/avatar/remove.
94
+//
95
+// We clear avatar_object_key on the user but DO NOT delete the stored
96
+// objects. The current key is content-addressed so future uploads land
97
+// at a new path; old objects stop being referenced and a future
98
+// orphan-sweep job (post-MVP) will purge them.
99
+func (h *Handlers) settingsAvatarRemove(w http.ResponseWriter, r *http.Request) {
100
+	user := middleware.CurrentUserFromContext(r.Context())
101
+	if err := h.q.UpdateUserAvatarKey(r.Context(), h.d.Pool, usersdb.UpdateUserAvatarKeyParams{
102
+		ID:              user.ID,
103
+		AvatarObjectKey: pgtype.Text{Valid: false},
104
+	}); err != nil {
105
+		h.d.Logger.ErrorContext(r.Context(), "avatar: db clear", "error", err)
106
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
107
+		return
108
+	}
109
+	http.Redirect(w, r, "/settings/profile", http.StatusSeeOther)
110
+}
111
+
112
+func (h *Handlers) renderAvatarError(w http.ResponseWriter, r *http.Request, msg string) {
113
+	row, err := h.q.GetUserByID(r.Context(), h.d.Pool, middleware.CurrentUserFromContext(r.Context()).ID)
114
+	if err != nil {
115
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
116
+		return
117
+	}
118
+	h.renderProfileForm(w, r, profileForm{
119
+		DisplayName: row.DisplayName,
120
+		Bio:         row.Bio,
121
+		Location:    row.Location,
122
+		Website:     row.Website,
123
+		Company:     row.Company,
124
+		Pronouns:    row.Pronouns,
125
+	}, msg, "")
126
+}
127
+
128
+func friendlyAvatarError(err error) string {
129
+	switch {
130
+	case errors.Is(err, avatars.ErrTooLarge):
131
+		return "Image is too large (max 5 MB)."
132
+	case errors.Is(err, avatars.ErrUnsupported):
133
+		return "That format isn't accepted. Use PNG, JPEG, or GIF."
134
+	case errors.Is(err, avatars.ErrDecompression):
135
+		return "Image dimensions are too large."
136
+	case errors.Is(err, avatars.ErrDecode):
137
+		return "We couldn't decode that image."
138
+	default:
139
+		return "Could not process upload."
140
+	}
141
+}
internal/web/handlers/auth/avatar_test.goadded
@@ -0,0 +1,124 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"bytes"
7
+	"image"
8
+	"image/color"
9
+	"image/png"
10
+	"io"
11
+	"mime/multipart"
12
+	"net/http"
13
+	"net/url"
14
+	"strings"
15
+	"testing"
16
+)
17
+
18
+func makeTestPNG(t *testing.T, w, h int) []byte {
19
+	t.Helper()
20
+	img := image.NewRGBA(image.Rect(0, 0, w, h))
21
+	for y := 0; y < h; y++ {
22
+		for x := 0; x < w; x++ {
23
+			img.Set(x, y, color.RGBA{R: 30, G: 60, B: 90, A: 255})
24
+		}
25
+	}
26
+	buf := &bytes.Buffer{}
27
+	if err := png.Encode(buf, img); err != nil {
28
+		t.Fatalf("encode: %v", err)
29
+	}
30
+	return buf.Bytes()
31
+}
32
+
33
+// uploadAvatar posts a multipart form to /settings/profile/avatar carrying
34
+// the PNG body under "avatar". Returns the http response.
35
+func uploadAvatar(t *testing.T, cli *client, csrf string, png []byte) *http.Response {
36
+	t.Helper()
37
+	body := &bytes.Buffer{}
38
+	mw := multipart.NewWriter(body)
39
+	if err := mw.WriteField("csrf_token", csrf); err != nil {
40
+		t.Fatalf("write csrf: %v", err)
41
+	}
42
+	part, err := mw.CreateFormFile("avatar", "test.png")
43
+	if err != nil {
44
+		t.Fatalf("create form file: %v", err)
45
+	}
46
+	if _, err := part.Write(png); err != nil {
47
+		t.Fatalf("write file: %v", err)
48
+	}
49
+	if err := mw.Close(); err != nil {
50
+		t.Fatalf("close mw: %v", err)
51
+	}
52
+	req, err := http.NewRequest("POST", cli.srv.URL+"/settings/profile/avatar", body)
53
+	if err != nil {
54
+		t.Fatalf("new req: %v", err)
55
+	}
56
+	req.Header.Set("Content-Type", mw.FormDataContentType())
57
+	req.Header.Set("Referer", cli.srv.URL+"/settings/profile")
58
+	resp, err := cli.c.Do(req)
59
+	if err != nil {
60
+		t.Fatalf("do: %v", err)
61
+	}
62
+	return resp
63
+}
64
+
65
+func TestAvatarUpload_Roundtrip(t *testing.T) {
66
+	t.Parallel()
67
+	cli := loginProfileUser(t)
68
+	csrf := cli.extractCSRF(t, "/settings/profile")
69
+	resp := uploadAvatar(t, cli, csrf, makeTestPNG(t, 600, 600))
70
+	defer func() { _ = resp.Body.Close() }()
71
+	if resp.StatusCode != http.StatusSeeOther {
72
+		body, _ := io.ReadAll(resp.Body)
73
+		t.Fatalf("status=%d body=%s", resp.StatusCode, body)
74
+	}
75
+	if got := resp.Header.Get("Location"); got != "/settings/profile" {
76
+		t.Fatalf("Location=%q", got)
77
+	}
78
+
79
+	// After upload, the profile page should now show the Remove button
80
+	// (HasAvatar=true).
81
+	resp = cli.get(t, "/settings/profile")
82
+	defer func() { _ = resp.Body.Close() }()
83
+	body, _ := io.ReadAll(resp.Body)
84
+	if !strings.Contains(string(body), "/settings/profile/avatar/remove") {
85
+		t.Fatalf("expected remove form post-upload, got: %s", body)
86
+	}
87
+}
88
+
89
+func TestAvatarUpload_RejectsNonImage(t *testing.T) {
90
+	t.Parallel()
91
+	cli := loginProfileUser(t)
92
+	csrf := cli.extractCSRF(t, "/settings/profile")
93
+	resp := uploadAvatar(t, cli, csrf, []byte("this is not an image"))
94
+	defer func() { _ = resp.Body.Close() }()
95
+	body, _ := io.ReadAll(resp.Body)
96
+	if !strings.Contains(string(body), "decode") && !strings.Contains(string(body), "format") {
97
+		t.Fatalf("expected decode/format error, got: %s", body)
98
+	}
99
+}
100
+
101
+func TestAvatarRemove_ClearsKey(t *testing.T) {
102
+	t.Parallel()
103
+	cli := loginProfileUser(t)
104
+	csrf := cli.extractCSRF(t, "/settings/profile")
105
+	resp := uploadAvatar(t, cli, csrf, makeTestPNG(t, 400, 400))
106
+	_ = resp.Body.Close()
107
+
108
+	csrf = cli.extractCSRF(t, "/settings/profile")
109
+	resp = cli.post(t, "/settings/profile/avatar/remove", url.Values{
110
+		"csrf_token": {csrf},
111
+	})
112
+	defer func() { _ = resp.Body.Close() }()
113
+	if resp.StatusCode != http.StatusSeeOther {
114
+		t.Fatalf("status=%d", resp.StatusCode)
115
+	}
116
+
117
+	// After remove, the profile page should NOT show the remove form.
118
+	resp = cli.get(t, "/settings/profile")
119
+	defer func() { _ = resp.Body.Close() }()
120
+	body, _ := io.ReadAll(resp.Body)
121
+	if strings.Contains(string(body), "/settings/profile/avatar/remove") {
122
+		t.Fatalf("expected no remove form after clearing avatar, got: %s", body)
123
+	}
124
+}