Allow user-uploaded square avatar (server-side center-crop, circular display, Regenerate/Remove preserved) #601

Closed
opened 2026-09-15 21:27:17 +00:00 by crueber · 1 comment
Owner

What's requested

Let a user upload their own avatar image on their profile, complementing the generated SVG avatar. The upload is a square image stored server-side, center-cropped to square, and displayed in the same circle treatment as generated avatars, with the existing Regenerate / Remove semantics preserved and the existing cache-busting contract upheld.

Current state (evidence)

  • User avatars are generated-only, by explicit design comment. internal/identity/avatar.go:53: "SVGs, never uploads (unlike #359 org avatars, which accept PNG/JPEG/GIF/WebP uploads and reject SVG)". The service writes users/<username>/avatar.svg (UserAvatarKey, avatar.go:191).
  • The full Regenerate/Remove lifecycle already exists and must keep working unchanged:
    • RegenerateUserAvatar (avatar.go:325) — POST, synchronous, self-or-admin.
    • DeleteUserAvatar (avatar.go:284) — DELETE, opts out (avatar_disabled), so later logins do NOT regenerate until the user regenerates; idempotent.
    • Routes: GET/POST/DELETE /api/v1/users/{principal}/avatar (internal/identity/http.go:402); SDK repos.users.avatar.{url,regenerate,remove} (web/sdk/src/users.js:37-50).
  • Cache-busting already exists and must be preserved: the UI cache-busts with ?v=<avatar_updated_at> (avatar.go:196-201, the #359 pattern — immutable cache class, pointer timestamp busts it); the GET handler sets the matching ETag user-avatar-<AvatarUpdatedAt> (http.go:438). Any upload path MUST bump AvatarUpdatedAt or clients serve stale avatars.
  • Org avatars already accept uploads — the machinery is prior art, not new work. internal/identity/orgs.go:104-129: maxOrgAvatarBytes 2 MiB cap (413 over cap), magic-sniff allowlist PNG/JPEG/GIF/WebP (415 otherwise, SVG rejected for same-origin script-execution reasons), single-flight hazard documented at orgs.go:375-388; PUT route at http.go:628.
  • Display is already circular. The navbar renders the avatar as a rounded-full h-8 w-8 circle with initial fallback (web/src/components/IdentityMenu.jsx:94-105); the profile-page asides gate on avatar_content_type and never probe bytes (web/src/pages/Repos.jsx:369-382); self-service Regenerate/Remove controls live on the owner profile page gated on isSelf (Repos.jsx:425-460).

Architecture notes

  • Extend the user-avatar service with an upload entry point mirroring PutOrgAvatar: same 2 MiB cap, same PNG/JPEG/GIF/WebP magic-sniff allowlist, same SVG rejection (the existing comment's prohibition was against SVG uploads for users, not against uploads generally — amend the comment to state the new design).
  • Center-crop server-side to square before storing, so any input aspect ratio renders correctly in the circle without client-side processing. Store the processed raster (no re-encoding to SVG; keep a raster content type on AvatarContentType). The implementer may choose the processing approach (pure-Go image package vs. a dependency) — if it needs a new module dependency, flag it as a dependency-law decision for the user, do not add silently.
  • Regenerate semantics with an uploaded avatar: keep the existing POST meaning ("install a fresh generated avatar") — for a user who uploaded, Regenerate should replace the upload with a generated one (and that is the documented opt-back-in path after Remove). Alternatively Regenerate could be hidden when an upload exists — implementer's call; pick one and note it. Remove (DELETE) keeps its current semantics for both kinds: clears the pointer and opts out.
  • Route surface: the existing /api/v1/users/{principal}/avatar path gains the upload verb. Note the current route set is GET/POST/DELETE; an upload is conventionally PUT (matching the org twin, http.go:628) but POST is defensible — implementer's call, keep it consistent with the org-avatar twin and wire all three route twins if any handler registration pattern requires it.
  • Cached-response caution: anything projecting avatar_url (discovery me() payload, internal/api/discovery.go:158-174) already derives from AvatarUpdatedAt, so a bump on upload flows through — verify the ETag coverage on any cached response carrying the field rather than assuming.

Acceptance criteria

  • A signed-in user can upload a square (or non-square, center-cropped) PNG/JPEG/GIF/WebP avatar from their profile self-service controls; it appears in the navbar circle and profile asides.
  • Over-cap uploads get 413; non-allowlisted types (including SVG) get 415; uploads by non-self/non-admin get the standard auth refusal.
  • Uploaded images are center-cropped to square server-side and render correctly in the circular treatment (no letterboxing/stretching).
  • AvatarUpdatedAt bumps on upload; navbar/profile refetches show the new avatar without a stale ?v= hit (ETag + cache-bust verified).
  • Regenerate and Remove keep working exactly as today (Remove still opts out; logins don't regenerate until explicit opt-back-in) — documented behavior for uploaded + generated states.
  • The generated-SVG-only comment in internal/identity/avatar.go:53 is amended to match the new design.
  • Render verification before close: headless DOM assertions and/or screenshot review of the navbar circle and profile asides with an uploaded avatar.
## What's requested Let a user upload their own avatar image on their profile, complementing the generated SVG avatar. The upload is a square image stored server-side, center-cropped to square, and displayed in the same circle treatment as generated avatars, with the existing Regenerate / Remove semantics preserved and the existing cache-busting contract upheld. ## Current state (evidence) - **User avatars are generated-only, by explicit design comment.** `internal/identity/avatar.go:53`: "SVGs, never uploads (unlike #359 org avatars, which accept PNG/JPEG/GIF/WebP uploads and reject SVG)". The service writes `users/<username>/avatar.svg` (`UserAvatarKey`, avatar.go:191). - **The full Regenerate/Remove lifecycle already exists** and must keep working unchanged: - `RegenerateUserAvatar` (avatar.go:325) — POST, synchronous, self-or-admin. - `DeleteUserAvatar` (avatar.go:284) — DELETE, opts out (`avatar_disabled`), so later logins do NOT regenerate until the user regenerates; idempotent. - Routes: GET/POST/DELETE `/api/v1/users/{principal}/avatar` (`internal/identity/http.go:402`); SDK `repos.users.avatar.{url,regenerate,remove}` (`web/sdk/src/users.js:37-50`). - **Cache-busting already exists** and must be preserved: the UI cache-busts with `?v=<avatar_updated_at>` (avatar.go:196-201, the #359 pattern — immutable cache class, pointer timestamp busts it); the GET handler sets the matching ETag `user-avatar-<AvatarUpdatedAt>` (http.go:438). Any upload path MUST bump `AvatarUpdatedAt` or clients serve stale avatars. - **Org avatars already accept uploads — the machinery is prior art, not new work.** `internal/identity/orgs.go:104-129`: `maxOrgAvatarBytes` 2 MiB cap (413 over cap), magic-sniff allowlist PNG/JPEG/GIF/WebP (415 otherwise, SVG rejected for same-origin script-execution reasons), single-flight hazard documented at orgs.go:375-388; PUT route at http.go:628. - **Display is already circular.** The navbar renders the avatar as a `rounded-full h-8 w-8` circle with initial fallback (`web/src/components/IdentityMenu.jsx:94-105`); the profile-page asides gate on `avatar_content_type` and never probe bytes (`web/src/pages/Repos.jsx:369-382`); self-service Regenerate/Remove controls live on the owner profile page gated on `isSelf` (Repos.jsx:425-460). ## Architecture notes - Extend the user-avatar service with an upload entry point mirroring `PutOrgAvatar`: same 2 MiB cap, same PNG/JPEG/GIF/WebP magic-sniff allowlist, same SVG rejection (the existing comment's prohibition was against SVG uploads for users, not against uploads generally — amend the comment to state the new design). - **Center-crop server-side to square** before storing, so any input aspect ratio renders correctly in the circle without client-side processing. Store the processed raster (no re-encoding to SVG; keep a raster content type on `AvatarContentType`). The implementer may choose the processing approach (pure-Go image package vs. a dependency) — if it needs a new module dependency, flag it as a dependency-law decision for the user, do not add silently. - Regenerate semantics with an uploaded avatar: keep the existing POST meaning ("install a fresh generated avatar") — for a user who uploaded, Regenerate should replace the upload with a generated one (and that is the documented opt-back-in path after Remove). Alternatively Regenerate could be hidden when an upload exists — implementer's call; pick one and note it. Remove (DELETE) keeps its current semantics for both kinds: clears the pointer and opts out. - Route surface: the existing `/api/v1/users/{principal}/avatar` path gains the upload verb. Note the current route set is GET/POST/DELETE; an upload is conventionally PUT (matching the org twin, http.go:628) but POST is defensible — implementer's call, keep it consistent with the org-avatar twin and wire all three route twins if any handler registration pattern requires it. - Cached-response caution: anything projecting `avatar_url` (discovery `me()` payload, `internal/api/discovery.go:158-174`) already derives from `AvatarUpdatedAt`, so a bump on upload flows through — verify the ETag coverage on any cached response carrying the field rather than assuming. ## Acceptance criteria - [ ] A signed-in user can upload a square (or non-square, center-cropped) PNG/JPEG/GIF/WebP avatar from their profile self-service controls; it appears in the navbar circle and profile asides. - [ ] Over-cap uploads get 413; non-allowlisted types (including SVG) get 415; uploads by non-self/non-admin get the standard auth refusal. - [ ] Uploaded images are center-cropped to square server-side and render correctly in the circular treatment (no letterboxing/stretching). - [ ] `AvatarUpdatedAt` bumps on upload; navbar/profile refetches show the new avatar without a stale `?v=` hit (ETag + cache-bust verified). - [ ] Regenerate and Remove keep working exactly as today (Remove still opts out; logins don't regenerate until explicit opt-back-in) — documented behavior for uploaded + generated states. - [ ] The generated-SVG-only comment in `internal/identity/avatar.go:53` is amended to match the new design. - [ ] Render verification before close: headless DOM assertions and/or screenshot review of the navbar circle and profile asides with an uploaded avatar.
crueber added this to the v1 milestone 2026-09-15 21:27:25 +00:00
Author
Owner

Fixed by #608 (merged): PUT avatar upload (PNG/JPEG/GIF, 2 MiB + 4096px/16Mpx bomb guards, stdlib center-crop to square PNG); WebP/SVG 415; AvatarUpdatedAt bumped; Regenerate replaces upload; Remove opts out both kinds; UI upload beside Regenerate/Remove (isSelf). Review caught + fixed a decompression-bomb hole pre-merge (no pixel bound → DecodeConfig gate). Verified: identity 95.5% cover, -race green, 1546 web unit green (smoke excluded, pre-existing), vite/esbuild green, independent review APPROVE.

Fixed by #608 (merged): PUT avatar upload (PNG/JPEG/GIF, 2 MiB + 4096px/16Mpx bomb guards, stdlib center-crop to square PNG); WebP/SVG 415; AvatarUpdatedAt bumped; Regenerate replaces upload; Remove opts out both kinds; UI upload beside Regenerate/Remove (isSelf). Review caught + fixed a decompression-bomb hole pre-merge (no pixel bound → DecodeConfig gate). Verified: identity 95.5% cover, -race green, 1546 web unit green (smoke excluded, pre-existing), vite/esbuild green, independent review APPROVE.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#601
No description provided.