feat(workspace) user avatars - #607
Open
QuanCheng-QC wants to merge 1 commit into
Open
Conversation
Users can upload an avatar from workspace settings. It follows them into every workspace they're a member of, and shows up in the team roster, chat messages and the account menu. Storage reuses the existing FileStore, so the bytes land wherever files already do — S3 in production, disk locally — with no new component. The database holds only the key; deliberately no FileRecord row, since those are listed per-workspace on the Files page and an avatar is neither workspace-scoped nor something a user should be able to delete from there. Two decisions worth calling out. The read URL carries no credential. An <img> tag can't send an Authorization header, and the obvious move — copying the `?token=` scheme from file downloads — would have leaked the workspace token, which bypasses role checks and is deliberately withheld from viewers. Instead the URL is the capability, a random 128-bit blob id that only appears in authenticated responses. Team rosters hand it out only to callers with a verified identity, since an open workspace's roster is anonymously readable. Deletion goes through a transactional outbox rather than a best-effort call after the commit. One S3 timeout would otherwise leave an avatar the user asked us to remove readable forever, with nothing recording that it should be gone. The pointer swap and the deletion record commit together; a drainer on the existing maintenance cycle empties the table with backoff. Uploads are re-encoded to a 512x512 WebP. That's the security boundary rather than a resize — it kills SVG/polyglot stored XSS, strips the GPS coordinates phone photos carry, and bounds decompression bombs. EXIF orientation is applied before the crop, or portrait photos land sideways. Adds migrations 030/031 and Pillow. Does NOT fix the pre-existing gap where DELETE /v1/account leaves the User and WorkspaceMembership rows behind — that needs a decision about workspaces the user owns, tracked separately.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Workspace had no user avatars. Everyone showed up as an email initial in the team roster, in chat messages and in the account menu.
Source: product request, raised during this development cycle. There is no corresponding Issue. This is not a bug report and not a test finding.
This is a new capability rather than a fix, so "before/after" below describes what the product could and could not do, not a defect.
How to reproduce the gap
As any signed-in user, in any workspace: open workspace settings, the team member list, or any chat thread, and look for a way to set a personal avatar. There is no entry point anywhere, and no endpoint that reads or writes a user avatar.
Behavior before
users, no endpoint.GET /v1/workspaces/{id}/teamreturned onlyemail / displayName / role / joinedAtand no stableuserId, leaving the frontend to use email as a person's identity.Behavior after
GET /v1/account/profilereturnsuserId / email / displayName / avatarUrl.userIdandavatarUrl.Design
Storage. Bytes go through the existing
FileStore, so they land wherever files already do — S3 in production, local disk in dev — with no new component and no new configuration. The key isavatars/{user_id}/{blob_id}.webp, built throughFileStore's existing four-argumentsave(), soapp/storage.pyneeded no change. The database stores only that key, inusers.avatar_key.No
FileRecordrow. The Files page listsFileRecords per workspace. An avatar is neither workspace-scoped nor something a user should be able to delete from a file browser, so it is deliberately kept out of that table and is invisible there.The read URL is the capability.
GET /v1/avatars/{user_id}/{blob_id}.webptakes no credential and does no database query. An<img>tag cannot send an Authorization header, and copying the?token=scheme from file downloads would have handed viewers the workspace token — which bypasses role checks and which/v1/account/workspacesdeliberately withholds from them. Insteadblob_idis 128 bits ofsecrets.token_hex(16), appearing only in authenticated responses. Team rosters withhold it from machine-token callers and from the anonymous read that open workspaces still permit.Random keys, not content hashes. Two concurrent uploads by the same user can never collide, so neither can delete the key the other just committed. Cache-busting comes free because the URL changes on every upload.
Deletion is a transactional outbox. The pointer swap and a row in the new
blob_deletionstable commit together, so an S3 outage cannot lose the intent.app/blob_gc.pydrains the table on the existing_run_maintenancecycle, claiming rows withFOR UPDATE SKIP LOCKEDand retrying with exponential backoff. The alternative — best-effort delete plus a log line — would leave an avatar the user asked us to remove readable forever, with nothing in the system recording that it should be gone.Uploads are re-encoded to a 512×512 WebP. This is the security boundary, not a resize: it kills SVG/polyglot stored XSS, strips the GPS coordinates phone photos carry, and bounds decompression bombs (pixel count is checked from the header before any pixel is allocated). EXIF orientation is applied before the crop, or portrait photos land sideways. Format is decided by magic bytes, never by the declared
Content-Type.Cache is
private, max-age=86400, noimmutable. Deleting a blob 404s the origin immediately; a browser that already fetched it keeps its copy for at most 24h.immutableplus a long max-age would have meant "never revocable".Scope and risk
New, no effect on existing behavior
app/avatar.py,app/blob_gc.py,app/routers/avatars.pyusers) and 031 (newblob_deletionstable)Changes existing behavior
GET /v1/workspaces/{id}/teamgainsuserIdandavatarUrl.avatarUrlis only sent to callers with a verifiable identity.DELETE /v1/accountnow also clears the avatar; itsdeletedmap gains anavatarcount._run_maintenancegains one outbox drain per cycle (~5 minutes).Deliberately left alone
GET /v1/account/workspacesis byte-for-byte unchanged. The Swift client decodes it as[AccountWorkspace], and the Go and web clients index it directly, so wrapping it would break three clients. Hence a separate/v1/account/profile.app/storage.pyandverify_workspace_accessare untouched.Known pre-existing gap, out of scope
DELETE /v1/accountstill does not delete theUserorWorkspaceMembershiprows, so identity data outlives the "delete my account" it promises. Fixing it first requires deciding what happens to workspaces the user owns; it deserves its own issue. This PR only guarantees the avatar bytes are reliably deleted.Deployment
New dependency:
Pillow>=10.0.0. Installed at image build fromrequirements.txt, so production needs no manual step. Note the failure mode: Pillow is imported lazily inside the transcode function, so a container missing it still boots and serves everything else — only avatar uploads return 500. It will not show up as a failed health check. Anyone running the backend from source must re-runpip install -r requirements.txt.Migrations 030 and 031 run automatically on Railway via
preDeployCommand = "alembic upgrade head"inworkspace/backend/railway.toml, once before replicas start. The container entrypoint does not run them. For any deployment not driven by that config (e.g. the ECS path in the migration guide),alembic upgrade headmust be run manually before the new image goes live.Ordering and rollback. Both migrations are purely additive — two nullable columns and one new table — so the old code runs fine against the new schema. Migrate first, then deploy. Rolling the code back needs no schema rollback. Reverting 031 would discard any pending deletions, so prefer leaving it in place.
No S3 or IAM change. Avatars use the existing bucket under an
avatars/prefix, and the task-role policy is bucket-wide (arn:aws:s3:::<bucket>/*), so no policy update is required.Memory ceiling worth tuning. Decoding runs under a semaphore that is per process. Railway runs 2 replicas × 4 uvicorn workers, so the defaults (
AVATAR_DECODE_CONCURRENCY=4,AVATAR_MAX_PIXELS=25M) allow up to 4 × 4 × ~100MB ≈ 1.6GB of decode buffers per replica under a burst. Consider loweringAVATAR_DECODE_CONCURRENCYto 2, orAVATAR_MAX_PIXELS, to match the actual pod memory. AllAVATAR_*settings have defaults and none is required to deploy.SQLite (local dev only). The schema comes from
create_allat startup, which creates new tables but does not add columns to existing ones. An existing.dbneedsALTER TABLE users ADD COLUMN avatar_key TEXTandavatar_updated_at TIMESTAMP, or every request touchingUserreturns 500 — and because a 500 loses its CORS headers, that surfaces in the browser as a cross-origin error rather than a server error.How to verify
Automated
The full suite goes from 546 to 581 passing with the same 64 pre-existing failures, so there are no regressions. Frontend
tsc --noEmitandnext buildboth pass.Manual — sign in, open workspace settings, scroll to Your avatar just above "Require login".
Action | Expected -- | -- Upload an image | Account menu and team list both update Post a message | The avatar renders beside it Upload a different image | New avatar applies, old URL returns 404 Remove it | Falls back to initials, old URL 404s Upload an .svg, and again renamed to .png | Both rejected with 400 Upload a portrait photo from a phone | Correct orientation, not sideways Upload something over 5MB | 413Verified end to end locally against SQLite with real Firebase sign-in.