Avatar uploads on Settings → Profile (replace URL-only field with file upload) #804
Replies: 2 comments
-
|
Thanks @bdunncompany — well-scoped, and the gotcha section on 1. Both paths via the existing Worth being explicit on intent: any deployment running more than one API instance needs S3 — filesystem on a Docker volume is single-instance only and doesn't survive horizontal scale-out. The filesystem path is the right default for the self-host single-container case (one operator, one box, one volume), but treat S3 as the production path. Add 2. Remove the URL field cleanly — no fallback. No mobile or external callers PATCH Migration: existing rows with external URLs keep rendering as-is (the 3. No
This sidesteps the 4. Rate limiting: add it. 5 uploads per hour per user. No existing per-user limiter — 5.
One thing to bake into the helper: bust the blob URL cache on upload and delete events (otherwise users see their old avatar until next page load). Refcounted with a manual invalidate-by-userId is enough; don't over-engineer it. Cross-cutting: the GET endpoint needs the same tenant scoping as Open as draft when ready. |
Beta Was this translation helpful? Give feedback.
-
|
Shipped in #1059 (merged 2026-06-08). Settings → Profile now has a real file-upload avatar replacing the URL-only field: magic-byte format sniff (rejects SVG/non-image even when extension lies), size cap, atomic temp+rename write, traversal-guarded path, and authed-blob serving via a tenant-scoped |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Avatar uploads for user profiles (replacing the URL-only field)
Title suggestion: Avatar uploads on Settings → Profile (replace URL-only field with file upload)
Category: Ideas / Feature requests
The problem
The Settings → Profile screen only lets users set an avatar by pasting a publicly-reachable HTTPS URL into a text field. That URL is stored on
users.avatar_urland rendered with an<img src>. This has two practical problems:<img>can't follow that, so the avatar shows up blank. No error, no feedback, just no picture.Concrete example: I'm running Breeze behind Cloudflare Access on a self-host. There's no URL I can paste into the field that renders, short of using a third-party image host. Every other tool in this category (and every SaaS in general) just lets you upload a file from the profile screen.
Even leaving aside the proxy case, "paste an internet-reachable URL" is friction that other products have abandoned. A
<input type="file">on the profile page covers the 90% case directly.Proposed solution
Replace the URL field with a file upload. Files land on the existing
api_dataDocker named volume, the same oneapps/api/src/services/fileStorage.tsalready uses forremote/transfers. No new infrastructure required; no new dependencies; no schema migration.Storage
/data/avatars/<userId>.<ext>on theapi_datavolume../data/transfers(chunked transfers),./data/patch-reports(patch reports), etc.isS3Configured()is already wired and we can branch the same wayapps/api/src/routes/software.ts:642does. Filesystem first, S3 as Phase 2 if needed, or the other way round if you'd rather not back up a new volume on hosted.API
Three new endpoints under existing
userRoutes(apps/api/src/routes/users.ts):POST /api/v1/users/me/avatar: multipart upload, singlefilefieldimage/png,image/jpeg,image/webp(no SVG to avoid embedded-JS XSS, no GIF in v1)Content-Type)/data/avatars/<userId>.<ext>atomically (write.tmp+rename())users.avatar_url = '/api/v1/users/<userId>/avatar'user.avatar.uploadGET /api/v1/users/:id/avatar: auth-required, streams the bytes,Cache-Control: private, max-age=300, weak ETag (mtime + size). Returns 404 when no avatar set so the frontend falls back to initials.DELETE /api/v1/users/me/avatar: unlinks the file, setsavatar_url = NULL, audit log entry.No image-processing dependency in v1. The 5 MB cap is the backstop against abuse. If anyone asks for server-side resize later,
sharpcan be added without changing the storage layout.UI
Replace the URL
<input>block inapps/web/src/components/settings/ProfilePage.tsxwith:Known gotcha worth flagging up front:
<img src>can't send Bearer authIf the GET endpoint requires Authorization,
<img src="/api/v1/users/<id>/avatar">401s, since the browser doesn't send the Bearer header for image requests. Two reasonable options:fetchWithAutheverything else uses, turns the response into an object URL, and feeds that to<img>. About 150 lines, refcounted across the topbar, dropdown, and profile preview so the bytes are fetched once. No server change required.<img>does send automatically). Simpler client, but requires the route to accept cookie-auth in addition to Bearer, and CSRF considerations apply to a GET in a way they don't otherwise.Either is defensible. Worth deciding before a PR lands so there's a single canonical approach in tree.
Schema
No changes.
users.avatar_url textalready exists and gets reused.Migration
None forced. Existing rows with external URLs keep rendering as-is. On the next profile save (or first upload), the field becomes the new internal
/api/v1/users/<userId>/avatarpath. The PATCH validator atapps/api/src/routes/users.tseither dropsavatarUrlfrom the schema (so upload is the only set path) or gets loosened to accept the internal path too. See open question 2 below.Open questions for the maintainer
/data/avatars/onapi_datavolume) as the lowest-friction path for the self-hosted majority. Is hosted SaaS OK with that, or does api scale horizontally enough that filesystem isn't shared, in which case S3-only on hosted + filesystem-or-S3 on self-hosted is a better split?avatarUrldirectly via the API (mobile? portal? external integration?), I'd keep it as a power-user fallback.sharp(relying on the 5 MB cap and CSS to render small), or want server-side resize on day 1?<img>auth: blob fetch vs cookie auth. (See gotcha above.) Which would you prefer in tree?Scope of work
apps/api/src/routes/users.tsavatarUrlfrom PATCH schema (or loosen it)apps/api/src/services/avatarStorage.ts(new)fileStorage.tspatternapps/api/src/routes/users.test.tsapps/web/src/components/settings/ProfilePage.tsxapps/web/src/lib/avatarBlobCache.ts(new, only if Option A)<img>can render an auth'd pathapps/web/src/components/settings/ProfilePage.test.tsxdocs/Roughly +500 LoC net, no new deps, no migration file, no env vars.
State of the work
I have all of the above implemented and running on my self-host instance. Tests pass, the upload/render/delete cycle works end-to-end in Chrome and Safari, and the topbar, dropdown, and profile preview all show the uploaded avatar via the blob-cache hook (Option A). Happy to open a PR, just want a quick read on questions 1, 2, and 5 first so the PR matches what you'd merge.
Beta Was this translation helpful? Give feedback.
All reactions