feat(share): user-customizable display name + avatar upload#374
Merged
Conversation
Adds end-to-end profile customization across desktop renderer and
share-web /me. Pattern follows AFFiNE's backend-proxied upload
shape — multipart to backend, MIME sniff from bytes, strip metadata,
write to R2 — adapted to Cloudflare Pages Functions (no NestJS, no
Rust NAPI).
Backend (share-backend):
- Migration 0002: add display_name TEXT, custom_avatar_id TEXT,
avatar_visible INTEGER NOT NULL DEFAULT 1 on users
- New R2 binding AVATARS (bucket spool-avatars)
- PATCH /api/me/profile — update display_name + avatar_visible
- POST /api/me/avatar — multipart upload:
* 1 MB cap, PNG/JPEG/WebP only
* MIME sniff from leading bytes (not Content-Type)
* Dimension bound 32..4096 px (parsed from header without
decoding the pixel array)
* EXIF / iTXt / iCCP / APP1 / APP2 / XMP / ICCP chunk strip
(pure JS byte-level pipeline, zero deps)
* Per-user 10/h upload rate limit
* Old avatar deleted from R2 after successful new write
* Audit log row written
- DELETE /api/me/avatar — idempotent removal
- GET /api/avatars/:user_id — serves from R2 with 5min cache
- GET /api/me — now returns resolved display_name + raw
display_name_override + custom_avatar_id + avatar_visible
- GET /api/profiles/:handle — uses resolved values
- Deletion worker — sweeps avatars/<user>/* from R2
Image pipeline:
- src/profile/image.ts: sniffMime / readDimensions / stripMetadata
(PNG eXIf/iTXt/tEXt/zTXt/iCCP; JPEG APP1/APP2/COM; WebP
EXIF/XMP/ICCP)
- src/profile/display-name.ts: validation (1..50 graphemes via
Intl.Segmenter, control/zero-width/BOM block) +
resolveDisplayName fallthrough
Desktop renderer:
- main/ipc/share-profile.ts: 4 IPC channels (display name /
avatar visible / upload / delete)
- preload exposes updateDisplayName, setAvatarVisible,
uploadAvatar, deleteAvatar on spoolShare
- renderer ProfileEditor component embedded in
SettingsAccount; uses Electron <input type=file> (no
dialog.showOpenDialog so the same code shape works on web)
- useShareAuth.ShareAuthUser extended with display_name,
display_name_override, custom_avatar_id, avatar_visible
- sharePublicUrl.ts: new resolveAvatarUrl helper prefixes
relative /api/avatars/* paths with the public origin
- 24 new i18n keys × 7 locales (profile_* under settings.account)
share-web:
- components/ProfileEditor.tsx — mirror of desktop component,
same backend endpoints, native file picker
- lib/api.ts — MeResponse extended, 4 new API helpers
- Me page embeds ProfileEditor between identity + handle claim,
refreshes only /api/me on changes (not /api/me/shares)
Tests:
- profile-image.test.ts — sniffMime, readDimensions,
stripMetadata (8 cases)
- profile-display-name.test.ts — validateDisplayName boundary +
impersonation guards (control + zero-width + BOM) +
resolveDisplayName fallthrough (15 cases)
- me.test.ts updated for new response shape
- deletion-worker tests updated to seed AVATARS R2 + assert
avatars/<user>/* cleanup
- fakes.ts: extended UserRow with optional new fields, new
SQL matchers for profile SELECT/UPDATE, makeR2 gains .list()
Backend: 210/210 green. tsc clean across app + share-web.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… review fixes
Iterative pass on the profile-customize feature after dogfooding revealed
gaps in the original implementation; bundles the UI redesign with the
correctness/security fixes a code-review surfaced.
UI (desktop + web):
- ProfileEditor: Notion-style identity row — clickable avatar with hover
Change overlay, save-on-blur name input, X badge top-right when a
custom avatar is set (revealed on hover only), separate Email row.
- Drop the inline visibility toggle + helper paragraph + Save button +
duplicate identity card; folded i18n down to the keys actually used.
- Tightened name/handle spacing on both surfaces.
Upload reliability:
- packages/app/src/main/share/api-client.ts: don't override
Content-Type when the body is FormData, otherwise the backend's
multipart parser sees application/json and 422s before reading the
bytes.
- packages/app/src/main/security/csp.ts: add backend origins to
img-src (localhost:8788 in dev, spool.pro + *.spool.pro in prod) so
the renderer can actually load the avatar it just uploaded — without
this Chromium drops the request silently and the <img> stays broken.
- packages/share-backend/.../api/me/index.ts +
packages/share-backend/.../api/profiles/[handle].ts: append
?v=<custom_avatar_id> to the avatar URL so a re-upload changes the
string, forcing React + the browser cache to refetch instead of
serving the previous image.
- DELETE /api/me/avatar also resets avatar_visible=1 so removing a
custom photo reliably reverts to the provider photo (or initials).
- Renderer: VITE_SPOOL_SHARE_BACKEND so avatar URLs prefix with the
backend origin directly (the share-web vite proxy was the previous
path, which only worked when that second dev server was up).
Security + correctness (review fixes):
- PNG dimension parse uses `>>> 0` so a width with the high bit set
reads as unsigned instead of negative — caught by code-review.
- JPEG metadata strip widened from {APP1, APP2, COM} to APP1..APP13 +
APP15 + COM; the old set left APP13 (Photoshop / IPTC, carries
geo + caption) untouched. APP0 (JFIF) + APP14 (Adobe) stay because
they're structural.
- GET /api/avatars/:user_id also checks avatar_visible so a stale URL
doesn't bypass the resolver-level hide.
- Deletion-worker avatar sweep pages through R2 list with the cursor
until truncated=false; the previous one-shot list would have left
the 1001st object orphaned.
Tests (+10):
- profile-image.test.ts: PNG high-bit width, JPEG APP1/APP13 strip,
APP0/APP14 preserved.
- deletion-worker.test.ts: 1001-object pagination sweep (with R2 fake
rewritten to honor truncated + cursor across mutations).
- avatars.test.ts: GET endpoint covers 200 served + 404 on missing +
404 on avatar_visible=0 + 404 on malformed id.
share-backend 220/220, share-web 44/44, app i18n + security 140/140,
desktop + web tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- MAX_AVATAR_BYTES 1 → 2 MB across backend lib + tests + both ProfileEditors - 7-locale error string updated - Drop the "AFFiNE" reference notes from the upload + image module headers; the design is described on its own terms Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Summary
Lets a signed-in share user set their own display name + upload a custom avatar that overrides the OAuth-provider claims. Bytes go through
/api/me/avatar, where the server sniffs MIME from the bytes (not the client-claimed Content-Type), strips EXIF/XMP/IPTC metadata, caps the dimensions, then writes to R2. The custom photo is served from/api/avatars/<user_id>with a per-upload cache-buster query.Surfaces touched: Spool desktop Settings → Account, share-web /me, and the public /@handle profile reads.
Changes
Backend (
packages/share-backend)0002_profile_customize.sql—display_name,custom_avatar_id,avatar_visiblecolumns onusersAVATARSR2 binding (wrangler.toml + dev script)PATCH /api/me/profile— display_name (Intl.Segmenter grapheme cap at 50) + avatar_visiblePOST /api/me/avatar— multipart upload, 2 MB cap, byte-level MIME sniff (PNG/JPEG/WebP), dimension parse without decoding, EXIF/XMP/ICCP/Photoshop metadata strip, R2 put + D1 commitDELETE /api/me/avatar— drops R2 object + resetsavatar_visible=1(so revert is consistent)GET /api/avatars/:user_id— serves from R2 with 5-min cache, etag on custom_avatar_id, gated bycustom_avatar_id IS NOT NULL+avatar_visible != 0/api/me+/api/profiles/[handle]— return resolveddisplay_name(override → provider → email local-part) + cache-buster?v=<custom_avatar_id>queryavatars/<user_id>/prefix, pages with cursor until truncated=falseDesktop (
packages/app)ProfileEditorcomponent: Notion-style avatar circle (hover Change overlay + X badge revealed on hover when a custom photo is set), save-on-blur name input, separate Email rowapi-client.ts— don't override Content-Type for FormData bodies (the override broke the backend multipart parser)csp.ts— backend origins added toimg-srcso the renderer can load/api/avatars/<id>VITE_SPOOL_SHARE_BACKENDso renderer avatar URLs prefix with backend directly (no second vite dev required)Web (
packages/share-web)ProfileEditoron/me(CSS class system instead of Tailwind, same UX)api.ts—updateDisplayName,setAvatarVisible,uploadAvatar,deleteAvatarhelpersWhy
Until now, display name + avatar were locked to the OAuth provider's claims. A user signed in with a corporate Google account had their work photo on every public share, with no override. The intent for v0.5 share-publish was that the public surface should be user-controlled — this PR delivers that.
The upload pipeline is backend-proxied (multipart → server-side MIME sniff + EXIF strip → R2 put) rather than browser-direct-to-R2 via presigned URLs. Direct upload would have been less code but pushes MIME/metadata trust to the client; we'd lose the ability to refuse a
.pngthat's actually executable bytes or strip a phone-camera GPS tag without the user noticing.Test plan
vitest run: 220 / 220 (+10 new: PNG high-bit width unsigned parse, JPEG APP1/APP13 strip, APP0/APP14 preserved, 1001-object avatar sweep pagination,GET /api/avatars/:idreturns 200 + 404 missing + 404 avatar_visible=0 + 404 malformed id)vitest run: 44 / 44tsc --noEmitcleanNotes
A separate follow-up PR (or stack) will recover share-web polish work that was implemented on 6/4 but never landed on main (Header
auth='auto'+ SWR me-cache,DeleteAccountModal+ footer link,UnpublishConfirmModal, sentence-case section labels + pills, Wordmark absolute URL,relativeDatehelper, plus more from the post-merge audit). Tracked inproject_share_web_design_recoverymemory.Submitter: @graydawnc
🤖 Generated with Claude Code