feat(users): avatar file upload (POST/GET/DELETE /users/me/avatar)#1059
Conversation
Replaces the URL-only avatar field on Settings → Profile with a real file
upload. Files are stored on the existing api_data Docker volume at
/data/avatars/<userId>.<ext>, mirroring the fileStorage.ts pattern used by
remote transfers.
API
- POST /users/me/avatar — multipart upload (single 'file' field)
- MIME allowlist: image/png, image/jpeg, image/webp (no SVG)
- Magic-byte verification (does NOT trust the Content-Type header)
- 5 MB cap enforced via bodyLimit middleware (413 on overflow)
- Atomic write (tmp + rename), unlinks prior file at a different extension
- Sets users.avatar_url = /api/v1/users/<userId>/avatar
- Audit: user.avatar.upload {mime, size, ext}
- GET /users/:id/avatar — auth-required, streams bytes
- Content-Type, Content-Length, Cache-Control: private, max-age=300
- Weak ETag (sha1 of size+mtime), supports 304 If-None-Match
- DELETE /users/me/avatar — unlinks file, clears avatar_url
- Audit: user.avatar.delete
avatarUrl removed from PATCH /users/me schema; the strict schema makes any
remaining caller surface a 400 instead of silently dropping the field. Avatar
edits go exclusively through the new endpoints.
Web
- ProfilePage.tsx now renders a 96px preview, file picker, drag-drop onto the
preview, and a Remove button (only when an avatar is set). Helper text:
"PNG, JPG, or WebP. Max 5 MB." Auth store updateUser is called on
success/delete so the topbar avatar refreshes immediately.
- URL text field removed (per design open question #2).
docker-compose.yml: AVATAR_STORAGE_PATH=/data/avatars added to the api env so
the service writes to the named volume instead of the container cwd.
Tests
- API: 9 new vitest cases — accept PNG/JPEG/WebP, reject SVG, reject
Content-Type/byte mismatch, reject empty, reject missing field, DELETE,
POST→GET roundtrip. Storage path is a per-run tmpdir.
- Web: 4 new tests — old URL field absent, upload happy path, validation on
unsupported type, DELETE clears the Remove button.
ToddHebebrand
left a comment
There was a problem hiding this comment.
Cross-tenant avatar read — must-fix. GET /:id/avatar (apps/api/src/routes/users.ts:479) serves the avatar for an arbitrary :id with no permission check and no tenant scoping. The sibling GET /:id (:750) gates on requirePermission(USERS_READ) and resolves through getScopedUser(...); this route does neither, so any authenticated user in any partner/org can enumerate and fetch any other user's avatar across tenants. The // tenant-scoped via auth middleware comment is inaccurate — the * partner-scope middleware only gates partner-scope full-org access, not per-id reads. Fix: resolve :id through getScopedUser(userId, scopeContext) before serving, or restrict the read route to /me.
Secondary (must-look): the GET handler sends headers including Content-Length, then on a stream error the bytes are already partially written — the client gets a truncated body under a 200 plus a full Content-Length (:514-523). Open the read inside the same try and prefer pipeline().
The upload path itself is solid — magic-byte sniffing (rejects SVG), Content-Type cross-check, bodyLimit + explicit size guard, atomic tmp+rename, filename derived from the authenticated user id (no traversal). Nicely done there.
Nice-to-have (non-blocking): nothing calls deleteAvatar on user deletion, so files leak on api_data indefinitely — wire it into the user-delete path. The web handlers (ProfilePage.tsx:1092,1129) hand-roll fetchWithAuth + try/catch instead of runAction (CLAUDE.md web-mutation rule) — adopt it or add a justified runActionAllowlist.ts entry. DELETE returns 200 even when no file existed.
Bouncing on the cross-tenant read; everything else is in good shape once that route is scoped.
…LanternOps#1059 review) Must-fix (cross-tenant read): GET /:id/avatar served any user's avatar by arbitrary id with no permission/tenant check — any authenticated user could fetch any other user's avatar across partners/orgs. Now mirrors GET /:id: a caller may always read their OWN avatar (top bar needs it without USERS_READ), but any other id must resolve via getScopedUser(userId, scopeContext) first; out-of-scope returns the same 404 as a missing avatar (no id enumeration). The inaccurate 'tenant-scoped via auth middleware' comment is removed. Must-look (truncated body): the handler set Content-Length then streamed, so a mid-stream read error produced a 200 with a short body. Avatars are capped at 5 MB, so read the whole file into a Buffer first (new readAvatarBuffer) — an I/O error is now a clean 500 before any header is sent, and the body is always exactly Content-Length. Drops the hono/streaming + readAvatarStream usage here. Tests: 3 new cases — cross-tenant read 404s even though the file exists; in-scope read serves the bytes; self read serves without consulting getScopedUser. Full API suite 5806 passed. Not done deliberately: deleteAvatar-on-delete (DELETE /:id removes a tenant MEMBERSHIP, not the global users row — calling deleteAvatar there would wipe a multi-tenant user's avatar while they're still active elsewhere; correct hook is the global hard-delete path, separate follow-up). runAction adoption + DELETE-when-no-file are noted for follow-up.
|
Thanks for catching the cross-tenant read — fixed in Must-fix (cross-tenant avatar read): Must-look (truncated body under 200): since avatars are capped at 5 MB, the handler now reads the whole file into a Buffer (new Tests: 3 new cases — a cross-tenant read Nice-to-haves:
|
…nternOps#1059) avatar_url is /api/v1/users/<id>/avatar, but a plain <img src> can't send the Bearer header the GET requires, so every served avatar fell back to a broken image after upload/reload. Add a refcounted useAvatarBlobUrl hook (lib/avatarBlobCache): fetches via fetchWithAuth, wraps the bytes in URL.createObjectURL, caches per-URL with refcounts, and revokes when the last consumer unmounts. External (http) URLs pass through after sanitizing. Wired into ProfilePage, Header (topbar + dropdown), and PortalHeader. This is the display half of the avatar feature — without it the upload path works but nothing renders. Cherry-picked from the prod follow-up to the upload commit. avatarBlobCache.test.ts covers cache hit/refcount/revoke + external-URL passthrough; ProfilePage test updated.
|
Also folded in the display half ( |
…isplay) users.avatar_url is stored as /api/v1/users/:id/avatar and round-trips through fetchWithAuth (the avatar blob fetch). buildApiUrl matched the bare /api/ branch first and slice(4) left /v1/users/:id/avatar, which then got /api/v1 prepended → /api/v1/v1/users/:id/avatar → 404 → broken avatar. Add an explicit /api/v1/ branch (slice 7) ahead of the /api/ branch. This is the last piece of the avatar feature working end-to-end: upload (earlier) + blob fetch (earlier) + correct URL here. The avatarBlobCache unit test mocks fetchWithAuth so it can't catch this; added a fetchWithAuth regression asserting an /api/v1/ path is not doubled. Full web suite 731 passed.
|
One more, and this one's load-bearing ( |
ToddHebebrand
left a comment
There was a problem hiding this comment.
Re-review — both round-21 must-fixes resolved. Clearing the block.
Cross-tenant read (must-fix): GET /:id/avatar now gates on userId !== auth.user.id → getScopedUser(userId, getScopeContext(auth)) (apps/api/src/routes/users.ts:563-585), the same resolution path as the sibling GET /:id. Nice touch returning the same 404 as a missing avatar so the route never reveals which user ids exist in other tenants (no enumeration oracle), and the own-avatar short-circuit keeps the top bar working without USERS_READ. The new GET /users/:id/avatar — cross-tenant authorization test block covers cross-partner → 404, in-scope → serves, own → serves without a scope lookup.
Truncated-stream (must-fix): now reads the whole size-capped buffer via readAvatarBuffer before sending any headers (users.ts GET handler) — an I/O error is a clean 500 instead of a 200 with a truncated body under a full Content-Length. Plus a ^[a-zA-Z0-9_-]+$ traversal guard and ETag/304 caching.
Both gates verified at file:line. Upload path was already solid (magic-byte sniff, size cap, atomic tmp+rename).
Status: review-clean, no must-fix remaining. Holding the merge only for a manual upload/display UI pass (top-bar + Profile authed-blob fetch). Non-blocking nice-to-have from the original review still open: no deleteAvatar on user-delete (file leak on account removal) — fine as a fast-follow.
fetchWithAuth unconditionally set Content-Type: application/json, which
stripped the multipart boundary the browser adds for FormData bodies. The
avatar POST then arrived without a boundary and the server could not parse
the file field, so every UI upload 400'd ("file field is required"). The
API and ProfilePage code were correct; the shared helper masked them.
Skip the JSON content-type when the body is FormData so the browser sets
multipart/form-data with its boundary. Verified end-to-end in a local
browser: upload (200 + render), bad-image reject (415), remove (DELETE).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed a fix directly to the branch ( Root cause: not in your avatar code. Fix: skip the forced JSON content-type when Verified end-to-end locally (browser): valid PNG → Re-reviewing for merge once CI re-runs. |
…3 polish (#1268) ## What Three fixes that came out of the v0.70 release-checklist testing pass, hardened by a multi-agent review round. **1. Avatar storage → DB bytea (the headline fix).** `POST /users/me/avatar` 500'd with `EACCES: permission denied, open '/data/avatars/….png.tmp'` — the API runs as `uid=1001(hono)` but the `api_data` volume's `/data/avatars` is `root:root 0755`, so filesystem avatar storage (#1059) was broken in any deployment with that ownership, prod included. Avatars now live on the user's own row as `avatar_data bytea` + `avatar_mime` + `avatar_updated_at` (migration `2026-06-11-j`): no volume dependency, works across replicas, and the existing dual-axis `users` RLS is the access boundary. `statAvatar` sizes via `octet_length()` so the conditional-GET 304 path never transfers the blob. The migration clears only dangling *internal* `/api/v1/users/%/avatar` URLs (external pre-upload-era URLs still resolve) and always logs the count. **2. React #418 hydration error on `/login`.** `cfAccessRedirectChecked` was seeded from a `typeof window` helper — `true` on the server (renders the form), `false` on a clean client load (renders the placeholder) — so every login visit hydrated mismatched trees. Now a constant `false` with the skip decision inside the effect; a regression test asserts `renderToString` output is identical with and without `window`. The CF-config fetch that gates the form also gets a 4s `AbortSignal.timeout` so a hung request can't pin login behind an empty placeholder. **3. Per-page 403 console error.** The sidebar fired `GET /admin/account-deletion-requests/pending-count` for every user, but the endpoint requires **platform admin** — so every page load logged a 403 for everyone (no platform admin exists in prod). `/users/me` and the login/MFA payloads now expose `isPlatformAdmin`, the store merges it on the preferences refresh (heals persisted pre-field sessions), and the sidebar hides the nav item + skips the badge fetch without it. ## Review round (multi-agent, both commits) The second commit addresses the findings: the **critical** one was that `isPlatformAdmin` initially only rode the `/users/me` response, which never reaches the store on password login — the gate would have hidden the nav from the exact users it serves. Also fixed there: schema/migration `timestamptz` drift, Sentry capture on the avatar write catch + soft-500 branches, invalid-`avatar_mime` detection (corruption no longer masquerades as "no avatar"), and stale filesystem-era comments. ## Testing - `users.test.ts` 53/53 — avatar I/O mocked behind an in-memory store with the real sniffing/ETag helpers kept live; new 304 conditional-GET and 500-path tests; vestigial `db.update` mocks removed - **New integration test** `avatar-bytea-roundtrip` 3/3 against the real driver as `breeze_app`: byte-exact round-trip (all 256 byte values), `octet_length` sizing, RLS fail-closed across partners (the #1257 class of gap mocked suites can't see) - `login.test.ts` 4/4 incl. new payload regression tests; `LoginPage.test.tsx` 8/8 incl. the hydration-invariant test - `pnpm db:check-drift` clean (237 files); web + api `tsc` clean - Not yet done: live browser verify of the upload (needs the API image rebuilt from this branch — the running local container predates it) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
Replaces the URL-only avatar field on Settings → Profile with a real file upload. Three new endpoints back it; files are stored on the existing
api_datavolume at/data/avatars/<userId>.<ext>, mirroring thefileStorage.tspattern used by remote transfers. No DB migration — reuses the existingusers.avatar_urlcolumn (now holds an internal/api/v1/users/<id>/avatarURL instead of an external one).API
/users/me/avatar— multipart upload (singlefilefield)image/png,image/jpeg,image/webp(no SVG)Content-TypeheaderbodyLimitmiddleware (413 on overflow)users.avatar_url = /api/v1/users/<id>/avatar; audituser.avatar.upload/users/:id/avatar— auth-required, streams bytes,Cache-Control: private, max-age=300, weak ETag + 304 support/users/me/avatar— unlinks file, clearsavatar_url; audituser.avatar.deleteBehavior change (please confirm the call)
avatarUrlis removed from thePATCH /users/meschema. Avatars now flow exclusively through the upload endpoints; the strict schema makes any client still sendingavatarUrlget a400(rather than a silent drop). This is the deliberate intent — switching from "paste a URL" to "upload a file." Consequences for the existing SOC2 audit tests, all updated in this PR:avatarUrlas its example non-email field now usesname(same intent: partner-scope self-change →user.profile.update).rejects avatarUrl with javascript: schemetest is removed — theavatarUrlURL-scheme refinement no longer exists, and "unknown field rejected" is already covered by the strict-schema test.If you'd rather keep the URL field alongside upload, say so and I'll rework to additive.
Web
ProfilePage.tsx: 96px preview, file picker, drag-drop onto the preview, and a Remove button (shown only when an avatar is set). Helper: "PNG, JPG, or WebP. Max 5 MB."updateUserfires on success/delete so the topbar avatar refreshes immediately. The URL text field is removed.docker-compose.yml: addsAVATAR_STORAGE_PATH=/data/avatarsto the api env (matches the existingTRANSFER_STORAGE_PATH/PATCH_REPORT_STORAGE_PATHpattern).Tests
tscclean; full API vitest 5803 passed / 0 failed (9 new avatar cases: accept PNG/JPEG/WebP, reject SVG, reject Content-Type/byte mismatch, reject empty, reject missing field, DELETE, POST→GET roundtrip; storage path is a per-run tmpdir).astro check0 errors; web vitest 721 passed / 0 failed (4 new ProfilePage cases).hono/body-limit,hono/streamingare existinghonosubmodules). No DB migration.