User-uploaded avatars: PUT upload with server-side square crop (Fix #601) #608
No reviewers
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub!608
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/issue-601"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements Forgejo #601: user-uploaded avatar (square, server-side center-crop, circular display, Regenerate/Remove preserved).
What: PUT on /api/v1/users/{principal}/avatar (self-or-admin, the org-twin verb — was free on the user path) installs a custom avatar: 2 MiB cap (413), PNG/JPEG/GIF magic-sniff (415 otherwise), server-side center-crop to square with PNG re-encode. GET serves either kind via the pointer Content-Type; the circular display and Regenerate/Remove controls are unchanged; upload control added on the owner page next to them (isSelf gate); SDK repos.users.avatar.upload; ?v=/ETag cache-bust bumped on every upload.
Decisions (all noted in code + docs/features/01_identity_permissions.md + PR):
Verification: go test ./internal/identity/... -race green; identity cover 95.5% (≥95% gate); gofmt/vet clean; go build ./... green; web full-minus-smoke 1546 pass; vite build + esbuild SDK bundle green (bundle contains the control); web/dist/.keep restored. 390px: input constrained w-full in the sidebar column, Tailwind-only, no one-off CSS (pinned in web test).
Implements docs/features/01_identity_permissions.md §8 + docs/go/12_web_ui.md (same commit, law 12): PUT /api/v1/users/{principal}/avatar installs a custom avatar — 2 MiB cap, PNG/JPEG/GIF magic-sniff, server-side center-crop to square PNG via pure stdlib (no new deps, law 1). Decisions (recorded in code + features doc): WebP 415-rejected with an explicit message (stdlib cannot decode WebP; org twin keeps its list — deliberate divergence); POST regenerate replaces an upload (stays visible, the opt-back-in); PUT on the existing path (org-twin verb, was free); single raster type (PNG re-encode); last-writer-wins concurrency (org-twin handling); uploads require an existing profile (404, no synthesis). Tests: Go upload round-trips (square + non-square → cropped square, centering proof), cap, SVG/WebP 415, ETag/?v= bump, regenerate-replaces, delete-opts-out both kinds, idempotency (identity 95.5% cover); web unit for SDK upload + control pins. gofmt/vet clean, -race green, web full-minus-smoke green (1546), vite + esbuild green.Independent review — PR #608 (Fix #601), branch
fix/issue-601Verdict: APPROVE with one hardening fix applied (
290f368, pushed to this branch by the reviewer — see §9). No large rework needed; all #601 acceptance criteria are met.Reviewed diff
origin/main..c46e5a4plus verification runs in the worktree. Coverage re-measured after the fix: 95.5% statements,-racegreen.1. Bucket key — PASS
Same
users/<username>/avatar.svgkey for both kinds (renaming would orphan existing avatars — correct call, law 5). GET servesprof.AvatarContentTypefrom the pointer (http.go:433), never sniffed from the suffix;PutBytessets matching objectContentTypeper kind.Repos.jsx:385gates display onavatar_content_type(no byte probing); ETag isuser-avatar-<AvatarUpdatedAt>(version-based, no suffix);UserAvatarURLbuilds a suffix-free path with?v=.grepoverinternal/identity+Repos.jsx+ SDK found no.svg-suffix branching. Bucket-compat reasoning holds: same key, content-type-driven.2. Image pipeline — PASS with one fix (see §9)
go.mod/go.sumuntouched by the branch; onlyimage(+/jpeg/png/gif)stdlib imports.side=min(w,h),ox=(w-side)/2— integer-division remainder ≤1px off-center on odd diffs, unavoidable and correct. Centering is pixel-pinned byTestCropSquarePNGCentersboth axes.uploadedUserAvatarContentType=image/png); animated-GIF first-frame collapse documented in code + docs.ErrInvalid→ 400 (pinned by "png magic but corrupt body rejected" case); 2 MiB cap enforced twice:MaxBytesReader(cap+1)at the handler (413) +lencheck pre-decode in the service (ErrTooLarge→ 413).290f368:DecodeConfigheader gate (4096px side / 16M px total → 400) before any pixel allocation; 4096 admits phone photos (4032×3024=12M). Tests + docs updated in the same commit (law 12).3. WebP/SVG 415 + org twin — PASS
Distinct messages pinned by tests: generic allowlist 415 for SVG/text, WebP 415 explicitly naming WebP (divergence from the org twin documented in code + docs with the stdlib rationale).
orgs.gohas zero diff — twin untouched.4. AvatarUpdatedAt / opt-out lifecycle — PASS
Upload bumps
AvatarUpdatedAtvia pointer CAS (ETag +?v=change pinned byTestUserAvatarUploadBumpsCacheBust); regenerate flows throughputUserAvatarwhich also stampsnow, so regenerate-replaces-upload busts caches too. Flag lifecycle verified in code + tests: install clearsAvatarDisabled(upload AND regenerate), DELETE sets it for both kinds;EnsureAvatarAsyncearly-returns onAvatarContentType != ""(upload survives logins) and onAvatarDisabled(delete→login→no-regen). Re-upload-after-delete opts back in (TestDeleteOptsOutUpload).5. Regenerate/DELETE/unknown-principal — PASS
Regenerate-replaces-upload stays visible beside the upload control — coherent (hiding it would strand the documented opt-back-in). DELETE is kind-agnostic pointer-clear + bytes best-effort delete, idempotent 200 (
curalready clear+disabled short-circuits). Unknown principal →ErrNotFound→ 404 on PUT (no synthesis); bad spelling → 400. Both pinned.6. Concurrency / routes / authZ — PASS
PutUserAvatarBytes=PutBytesthen pointer CAS, no lock held across store calls — mirrors the org-twin comment block. Last-writer-wins documented. Route tableGET/PUT/POST/DELETEcollision-free (POST owns regenerate; oldPUT→405test correctly moved toPATCH→405;exposed_testgains the PUT route row, both lanes delegate pre-method). AuthZself-or-adminin the handler; explicit tests: foreign PUT→403, anon PUT→401 (plus ghost-via-admin→404 proving the check order).7. UI — PASS
Upload
<label>+<input type=file>underShow when={isSelf()}beside Regenerate; preview is the existingrounded-full h-24 w-24circle (zero new display code);accept="image/png,image/jpeg,image/gif"matches the server allowlist;refreshAvatar()invalidatesuser:+meso navbar + asides refetch the?v=-busted URL; 413/415 mapped to allowlist notes. Tailwind-only (w-full text-xs, shared.muted/.btn), input constrained to the sidebar column (390px pin inuser-avatar-upload-601.test.js). SDKusers.avatar.upload(principal, data, {contentType})is shape-identical to theorgs.avatar.uploadtwin (modulo each module's local call convention).8. Coverage / pre-fix red / docs / comment — PASS
go test ./internal/identity/... -race -cover: 95.5% (≥95% gate holds, before and after290f368).origin/main(grepcount 0, test file not present) — the new suite cannot compile pre-fix.01_identity_permissions.md(route table + §8 decision, incl. the bomb-guard amendment) and12_web_ui.mdupdated in-branch.avatar.go:53prohibition comment rewritten to the two-kind design (generated SVG vs uploaded raster, SVG-upload rejection retained with the same-origin rationale).9. Reviewer fix commit (pushed to
fix/issue-601)290f368— Bound user-avatar decode dimensions (4096px/16M px, 400):DecodeConfiggate incropSquarePNG,TestCropSquarePNGRejectsDimensions(side/pixel rejects at crop+service+HTTP layers, no-clobber pin, photo-sized pass-through), docs route-table + decision-note amendment.gofmt/vetclean, identity-race+ cover (95.5%) green, affectednode --testsuites (12 tests) green.