Skip to content

feat(share): user-customizable display name + avatar upload#374

Merged
graydawnc merged 3 commits into
mainfrom
feat/share-profile-customize
Jun 9, 2026
Merged

feat(share): user-customizable display name + avatar upload#374
graydawnc merged 3 commits into
mainfrom
feat/share-profile-customize

Conversation

@graydawnc

@graydawnc graydawnc commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

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)

  • Migration 0002_profile_customize.sqldisplay_name, custom_avatar_id, avatar_visible columns on users
  • New AVATARS R2 binding (wrangler.toml + dev script)
  • PATCH /api/me/profile — display_name (Intl.Segmenter grapheme cap at 50) + avatar_visible
  • POST /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 commit
  • DELETE /api/me/avatar — drops R2 object + resets avatar_visible=1 (so revert is consistent)
  • GET /api/avatars/:user_id — serves from R2 with 5-min cache, etag on custom_avatar_id, gated by custom_avatar_id IS NOT NULL + avatar_visible != 0
  • /api/me + /api/profiles/[handle] — return resolved display_name (override → provider → email local-part) + cache-buster ?v=<custom_avatar_id> query
  • Deletion worker — sweeps avatars/<user_id>/ prefix, pages with cursor until truncated=false
  • Per-user upload rate limit: 10/h

Desktop (packages/app)

  • New ProfileEditor component: 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 row
  • 4 IPC handlers: update-display-name, set-avatar-visible, upload-avatar (ArrayBuffer → FormData → multipart), delete-avatar
  • api-client.ts — don't override Content-Type for FormData bodies (the override broke the backend multipart parser)
  • csp.ts — backend origins added to img-src so the renderer can load /api/avatars/<id>
  • VITE_SPOOL_SHARE_BACKEND so renderer avatar URLs prefix with backend directly (no second vite dev required)
  • i18n: 25 new keys across 7 locales

Web (packages/share-web)

  • Mirror ProfileEditor on /me (CSS class system instead of Tailwind, same UX)
  • api.tsupdateDisplayName, setAvatarVisible, uploadAvatar, deleteAvatar helpers

Why

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 .png that's actually executable bytes or strip a phone-camera GPS tag without the user noticing.

Test plan

  • share-backend 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/:id returns 200 + 404 missing + 404 avatar_visible=0 + 404 malformed id)
  • share-web vitest run: 44 / 44
  • app i18n + security narrow: 140 / 140 (locales.test.ts enforces 100% key parity across en/de/fr/ja/ko/zh-CN/zh-TW, CSP.test.ts checks dev + prod policies)
  • desktop + web tsc --noEmit clean
  • Manual dogfooding in worktree: sign in, upload PNG + JPEG, replace, X-badge remove → revert to Google photo, name save-on-blur, save with control chars / 50-char cap, EXIF GPS strip verified by re-downloading the served file

Notes

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, relativeDate helper, plus more from the post-merge audit). Tracked in project_share_web_design_recovery memory.

Submitter: @graydawnc

🤖 Generated with Claude Code

graydawnc and others added 3 commits June 8, 2026 21:52
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>
@graydawnc
graydawnc added this pull request to the merge queue Jun 9, 2026
Merged via the queue into main with commit 17d5a28 Jun 9, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-profile-customize branch June 9, 2026 03:19
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.

1 participant