Skip to content

feat(users): avatar file upload (POST/GET/DELETE /users/me/avatar)#1059

Merged
ToddHebebrand merged 5 commits into
LanternOps:mainfrom
bdunncompany:feat/user-avatar-upload
Jun 8, 2026
Merged

feat(users): avatar file upload (POST/GET/DELETE /users/me/avatar)#1059
ToddHebebrand merged 5 commits into
LanternOps:mainfrom
bdunncompany:feat/user-avatar-upload

Conversation

@bdunncompany

Copy link
Copy Markdown
Collaborator

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_data volume at /data/avatars/<userId>.<ext>, mirroring the fileStorage.ts pattern used by remote transfers. No DB migration — reuses the existing users.avatar_url column (now holds an internal /api/v1/users/<id>/avatar URL instead of an external one).

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 via bodyLimit middleware (413 on overflow)
    • Atomic write (tmp + rename), unlinks a prior file at a different extension
    • Sets users.avatar_url = /api/v1/users/<id>/avatar; audit user.avatar.upload
  • GET /users/:id/avatar — auth-required, streams bytes, Cache-Control: private, max-age=300, weak ETag + 304 support
  • DELETE /users/me/avatar — unlinks file, clears avatar_url; audit user.avatar.delete

Behavior change (please confirm the call)

avatarUrl is removed from the PATCH /users/me schema. Avatars now flow exclusively through the upload endpoints; the strict schema makes any client still sending avatarUrl get a 400 (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:

  • The audit-attribution test that used avatarUrl as its example non-email field now uses name (same intent: partner-scope self-change → user.profile.update).
  • The rejects avatarUrl with javascript: scheme test is removed — the avatarUrl URL-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." updateUser fires on success/delete so the topbar avatar refreshes immediately. The URL text field is removed.

docker-compose.yml: adds AVATAR_STORAGE_PATH=/data/avatars to the api env (matches the existing TRANSFER_STORAGE_PATH / PATCH_REPORT_STORAGE_PATH pattern).

Tests

  • API tsc clean; 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).
  • Web astro check 0 errors; web vitest 721 passed / 0 failed (4 new ProfilePage cases).
  • No new dependencies (hono/body-limit, hono/streaming are existing hono submodules). No DB migration.

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 ToddHebebrand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@bdunncompany

Copy link
Copy Markdown
Collaborator Author

Thanks for catching the cross-tenant read — fixed in b9f08fda.

Must-fix (cross-tenant avatar read): GET /:id/avatar now mirrors GET /:id — a caller may always read their own avatar (the top bar needs it without USERS_READ), but any other id must resolve through getScopedUser(userId, scopeContext) first. Out-of-scope returns the same 404 as a missing avatar, so it never reveals which user ids exist in other tenants. The inaccurate // tenant-scoped via auth middleware comment is gone.

Must-look (truncated body under 200): since avatars are capped at 5 MB, the handler now reads the whole file into a Buffer (new readAvatarBuffer) before sending any headers, then returns it with an exact Content-Length. An I/O error is a clean 500 before the response starts, instead of a 200 with a short body. Dropped the hono/streaming path here.

Tests: 3 new cases — a cross-tenant read 404s even though the file exists on disk; an in-scope read serves the bytes; a self read serves without consulting getScopedUser. Full API suite 5806 passed; eslint/Trivy/Gitleaks clean.

Nice-to-haves:

  • deleteAvatar on user deletion — deliberately not wired into DELETE /:id, because that route removes a tenant membership (partnerUsers/organizationUsers), not the global users row. Calling deleteAvatar(userId) there would wipe a multi-tenant user's avatar while they're still active in another tenant. The right hook is the global hard-delete path — happy to do it as a focused follow-up.
  • runAction adoption in ProfilePage.tsx and the DELETE-when-no-file 200 — noted; can fold into the same follow-up or this PR, your call.

…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.
@bdunncompany

Copy link
Copy Markdown
Collaborator Author

Also folded in the display half (a6beda89): avatar_url is /api/v1/users/<id>/avatar, but a plain <img src> can't send the Bearer header the GET requires — so without this the upload worked but the served avatar rendered as a broken image. Added a refcounted useAvatarBlobUrl hook (lib/avatarBlobCache) that fetches via fetchWithAuthcreateObjectURL → revokes on last-unmount, wired into ProfilePage + Header + PortalHeader (external URLs pass through after sanitizing). avatarBlobCache.test.ts covers cache hit / refcount / revoke / external passthrough. Full web suite 730 passed. Now the feature is complete end-to-end — sorry for the two pushes; better you review it whole.

…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.
@bdunncompany

Copy link
Copy Markdown
Collaborator Author

One more, and this one's load-bearing (024da3cd): the blob fetch I added still 404'd because buildApiUrl doubled the prefix — /api/v1/users/:id/avatar matched the bare /api/ branch first and became /api/v1/v1/users/:id/avatar. Added an explicit /api/v1/ branch. So the full chain is now: upload → blob fetch → correct URL. The blob-cache unit test mocks fetchWithAuth, so it couldn't catch this; added a fetchWithAuth regression that asserts an /api/v1/ path isn't doubled. That's the avatar feature genuinely working end-to-end now — last push on this, promise.

ToddHebebrand
ToddHebebrand previously approved these changes Jun 2, 2026

@ToddHebebrand ToddHebebrand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.idgetScopedUser(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>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Pushed a fix directly to the branch (ed9a4284) after the avatar upload 400'd in a local browser test — flagging since it touches shared code.

Root cause: not in your avatar code. fetchWithAuth (apps/web/src/stores/auth.ts:330) called headers.set('Content-Type', 'application/json') unconditionally, so the headers: {} bypass in ProfilePage.tsx couldn't take effect. The FormData body went out as application/json with no multipart boundary, and the server's parseBody found no file → 400 {"error":"file field is required"}. The API side and the upload UI were both correct; the shared helper masked them.

Fix: skip the forced JSON content-type when options.body instanceof FormData so the browser sets multipart/form-data with its boundary. Also dropped the now-unneeded headers: {} and corrected the stale comment. Added a regression test in auth.test.ts asserting Content-Type is left unset for FormData.

Verified end-to-end locally (browser): valid PNG → 200, avatar renders + authed-blob GET 200; text-renamed-.png → server 415 "Unsupported image format" with the prior avatar preserved; Remove → DELETE → back to initials. auth.test.ts + ProfilePage.test.tsx 21/21, tsc clean.

Re-reviewing for merge once CI re-runs.

@ToddHebebrand ToddHebebrand merged commit 8579ce2 into LanternOps:main Jun 8, 2026
24 of 25 checks passed
ToddHebebrand added a commit that referenced this pull request Jun 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants