From f24c6e20213492754e394992be663ead121d1e54 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sat, 20 Jun 2026 02:05:09 -0400 Subject: [PATCH] feat(avatar): self-host avatars on R2 on Workers + fix avatar refresh/fallback (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(avatar): refresh session after change and show fallback on remove Two avatar-display bugs surfaced post-#456: - After uploading/removing an avatar the UI showed the old image until a full reload. refreshSession() now calls getSession({ disableCookieCache: true }) to re-read user.image past the 5-min session cookie cache, then $store.notify('$sessionSignal') so useSession() actually refetches and re-renders (an external endpoint never toggles better-auth's session signal). - Removing an avatar left a blank circle: the conditional `{user.image && }` unmounts the radix Image, which keeps a stale "loaded" status so the Fallback stays hidden. Always render so radix re-runs its loading status (src -> undefined => "error") and shows the initials. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(avatar): self-host avatars on R2 when deployed on Workers The image-upload gateway now branches per request on the AVATARS R2 binding: - binding present (Cloudflare) -> upload straight to R2 (key `scope/id`, content type in R2 metadata, a content-hash `?v=` cache-buster) and return either an AVATARS_PUBLIC_URL (R2 custom domain) URL or a relative /api/avatar-blobs URL. - binding absent (Node/Docker, or a Worker without it) -> the existing ZPan Cloud avatar service, unchanged. Adds a public GET /api/avatar-blobs/:scope/:id route that streams the blob from the AVATARS binding (so local miniflare, which gives R2 no public URL, can serve avatars too). AVATARS_BINDING / R2BucketLike live in platform/interface so both the adapter and the http route can use them without crossing the arch boundary. wrangler.toml declares the AVATARS bucket (prod + staging). Co-Authored-By: Claude Opus 4.8 (1M context) * ci(deploy): provision the zpan-avatars R2 bucket + AVATARS_PUBLIC_URL The AVATARS R2 binding added for self-hosted avatars needs the bucket to exist on deploy. Mirror the resource-provisioning pattern (D1/Queue): create zpan-avatars if missing, enable its managed public URL, and upsert AVATARS_PUBLIC_URL so prod serves avatars straight from R2 (zero Worker egress). Without the secret the app still works via its /api/avatar-blobs route. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(avatar): reuse the original PUBLIC_IMAGES bucket/binding/env names Keep the same Cloudflare resource names as before #456 removed them so the existing `zpan-public-images` bucket is reused (not orphaned) and the API token scopes still apply: R2 binding PUBLIC_IMAGES, bucket zpan-public-images(-staging), public-URL secret PUBLIC_IMAGES_URL. Pure rename of the AVATARS naming I'd introduced — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-cloudflare.yml | 57 +++++++++ server/adapters/gateways/image-upload.test.ts | 109 +++++++++++++++++- server/adapters/gateways/image-upload.ts | 107 +++++++++++++---- server/app.ts | 3 + server/http/avatar-blobs.ts | 20 ++++ server/platform/interface.ts | 20 ++++ src/components/layout/user-account-menu.tsx | 2 +- .../_authenticated/settings/profile.tsx | 16 ++- wrangler.toml | 11 ++ 9 files changed, 315 insertions(+), 30 deletions(-) create mode 100644 server/http/avatar-blobs.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 9624d62f..fc5594b8 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -106,6 +106,63 @@ jobs: echo "Created queue: zpan-archive-jobs" fi + # Avatars are self-hosted in R2 on Workers (the PUBLIC_IMAGES binding). Provision the + # bucket so the binding resolves, expose its managed public URL, and pin it to + # PUBLIC_IMAGES_URL so prod serves avatars straight from R2 (zero Worker egress); + # without the secret the app falls back to its own /api/avatar-blobs route. + - name: Ensure R2 public-images bucket exists + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + EXISTS=$(curl -sf \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + | jq -r '.result.buckets[]? | select(.name == "zpan-public-images") | .name') + if [ -z "$EXISTS" ]; then + curl -sf -X POST \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "zpan-public-images"}' > /dev/null + echo "Created R2 bucket: zpan-public-images" + else + echo "Reusing R2 bucket: zpan-public-images" + fi + + - name: Enable R2 managed public URL + capture + id: r2 + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + # Idempotent: PUT enabled=true — CF returns the same pub-.r2.dev + # on every call once enabled. + curl -sf -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' > /dev/null + + DOMAIN=$(curl -sf \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/r2/buckets/zpan-public-images/domains/managed" \ + -H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result.domain') + + if [ -z "$DOMAIN" ] || [ "$DOMAIN" = "null" ]; then + echo "::error::Failed to retrieve R2 managed public domain. Make sure CLOUDFLARE_API_TOKEN has 'R2 Storage: Edit' scope." + exit 1 + fi + echo "url=https://$DOMAIN" >> "$GITHUB_OUTPUT" + echo "R2 public URL: https://$DOMAIN" + + - name: Set PUBLIC_IMAGES_URL (always upsert — R2 domain is stable) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + echo "${{ steps.r2.outputs.url }}" | pnpm exec wrangler secret put PUBLIC_IMAGES_URL + echo "Set PUBLIC_IMAGES_URL = ${{ steps.r2.outputs.url }}" + - name: Patch wrangler.toml with D1 database ID run: sed -i "s/database_id = \"[^\"]*\"/database_id = \"${{ steps.d1.outputs.id }}\"/" wrangler.toml diff --git a/server/adapters/gateways/image-upload.test.ts b/server/adapters/gateways/image-upload.test.ts index a4571a4b..c56cd0a6 100644 --- a/server/adapters/gateways/image-upload.test.ts +++ b/server/adapters/gateways/image-upload.test.ts @@ -9,8 +9,28 @@ type Any = any const AVATAR_PREFIX = '_system/avatars' const LOGO_PREFIX = '_system/org-logos' -function mockPlatform(env: Record = {}): Platform { - return { db: {} as Any, getEnv: (k: string) => env[k], getBinding: () => undefined } as unknown as Platform +function mockPlatform(env: Record = {}, avatarsBucket?: unknown): Platform { + return { + db: {} as Any, + getEnv: (k: string) => env[k], + getBinding: (k: string) => (k === 'PUBLIC_IMAGES' ? avatarsBucket : undefined), + } as unknown as Platform +} + +// In-memory R2 stand-in capturing put/get/delete the gateway issues. +function mockR2Bucket() { + const store = new Map() + const put = vi.fn(async (key: string, value: ArrayBuffer, opts?: { httpMetadata?: { contentType?: string } }) => { + store.set(key, { body: value, contentType: opts?.httpMetadata?.contentType }) + }) + const get = vi.fn(async (key: string) => { + const o = store.get(key) + return o ? { arrayBuffer: async () => o.body, httpMetadata: { contentType: o.contentType } } : null + }) + const del = vi.fn(async (key: string) => { + store.delete(key) + }) + return { bucket: { put, get, delete: del }, put, get, delete: del, store } } function activeBinding(refreshToken: string | null = 'refresh-token'): LicenseState { @@ -250,3 +270,88 @@ describe('deletePublicImageVariants — Cloud avatar service', () => { await expect(gw.deletePublicImageVariants(mockPlatform(), LOGO_PREFIX, 'team-1')).resolves.toBeUndefined() }) }) + +describe('uploadPublicImage — PUBLIC_IMAGES R2 binding (self-hosted, no Cloud)', () => { + it('uploads straight to R2 and returns the instance serve URL, never calling Cloud', async () => { + const r2 = mockR2Bucket() + const { gateway, createAvatarUploadClient } = mockLicensingCloud({}) + const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway) + + const result = await gw.uploadPublicImage(mockPlatform({}, r2.bucket), AVATAR_PREFIX, 'u1', makeFile('image/png')) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.url).toMatch(/^\/api\/avatar-blobs\/user\/u1\?v=[0-9a-f]{12}$/) + expect(r2.put).toHaveBeenCalledOnce() + expect(r2.put.mock.calls[0]?.[0]).toBe('user/u1') + expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } }) + // The R2 binding short-circuits the Cloud path entirely. + expect(createAvatarUploadClient).not.toHaveBeenCalled() + }) + + it('uses PUBLIC_IMAGES_URL (R2 custom domain) and the team scope for org logos', async () => { + const r2 = mockR2Bucket() + const { gateway } = mockLicensingCloud({}) + const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway) + + const result = await gw.uploadPublicImage( + mockPlatform({ PUBLIC_IMAGES_URL: 'https://cdn.example.com/' }, r2.bucket), + LOGO_PREFIX, + 'team-1', + makeFile('image/webp'), + ) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.url).toMatch(/^https:\/\/cdn\.example\.com\/team\/team-1\?v=[0-9a-f]{12}$/) + expect(r2.put.mock.calls[0]?.[0]).toBe('team/team-1') + }) + + it('still validates mime/size before touching R2', async () => { + const r2 = mockR2Bucket() + const { gateway } = mockLicensingCloud({}) + const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway) + + const bad = await gw.uploadPublicImage( + mockPlatform({}, r2.bucket), + AVATAR_PREFIX, + 'u1', + makeFile('application/pdf'), + ) + expect(bad.ok).toBe(false) + if (!bad.ok) expect(bad.status).toBe(400) + + const big = await gw.uploadPublicImage( + mockPlatform({}, r2.bucket), + AVATAR_PREFIX, + 'u1', + makeFile('image/png', 2 * 1024 * 1024), + ) + expect(big.ok).toBe(false) + if (!big.ok) expect(big.status).toBe(413) + + expect(r2.put).not.toHaveBeenCalled() + }) +}) + +describe('deletePublicImageVariants — PUBLIC_IMAGES R2 binding', () => { + it('deletes straight from R2 for the scope/id, never calling Cloud', async () => { + const r2 = mockR2Bucket() + const { gateway, createAvatarUploadClient } = mockLicensingCloud({}) + const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway) + + await gw.deletePublicImageVariants(mockPlatform({}, r2.bucket), AVATAR_PREFIX, 'u1') + + expect(r2.delete).toHaveBeenCalledWith('user/u1') + expect(createAvatarUploadClient).not.toHaveBeenCalled() + }) + + it('swallows R2 delete failures (best-effort)', async () => { + const r2 = mockR2Bucket() + r2.delete.mockRejectedValueOnce(new Error('boom')) + const { gateway } = mockLicensingCloud({}) + const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway) + + await expect( + gw.deletePublicImageVariants(mockPlatform({}, r2.bucket), LOGO_PREFIX, 'team-1'), + ).resolves.toBeUndefined() + }) +}) diff --git a/server/adapters/gateways/image-upload.ts b/server/adapters/gateways/image-upload.ts index 4b470768..3246a9ed 100644 --- a/server/adapters/gateways/image-upload.ts +++ b/server/adapters/gateways/image-upload.ts @@ -8,7 +8,7 @@ import { uploadAvatar, } from 'zpan-cloud-sdk' import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants' -import type { Platform } from '../../platform/interface' +import { type Platform, PUBLIC_IMAGES_BINDING, type R2BucketLike } from '../../platform/interface' import { AVATAR_PREFIX, type ImageUpload, @@ -30,6 +30,29 @@ function prefixToScope(prefix: string): AvatarScope { throw new Error(`Unknown image prefix: ${prefix}`) } +// The stable R2 object key: scope + entity id, no extension (the content type rides in +// R2 metadata, so one key serves any image format). One key per entity → re-uploads +// overwrite in place; the `?v=` cache-buster on the URL forces browsers to refetch. +function avatarKey(scope: AvatarScope, id: string): string { + return `${scope}/${id}` +} + +async function contentVersion(buffer: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest('SHA-256', buffer) + return Array.from(new Uint8Array(digest).slice(0, 6)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +// Public URL for an R2-hosted avatar. With PUBLIC_IMAGES_URL set (an R2 custom domain) +// the browser hits R2 directly (no Worker egress); otherwise it goes through this +// instance's own /api/avatar-blobs serve route (works in local miniflare too, where R2 +// has no public URL). A relative URL resolves against the instance origin. +function r2AvatarUrl(platform: Platform, key: string, version: string): string { + const base = platform.getEnv('PUBLIC_IMAGES_URL')?.replace(/\/$/, '') + return base ? `${base}/${key}?v=${version}` : `/api/avatar-blobs/${key}?v=${version}` +} + function cloudErrorCode(data: unknown): string | null { if (!data || typeof data !== 'object' || !('error' in data)) return null const error = (data as { error: unknown }).error @@ -58,10 +81,11 @@ async function cloudUploadError(res: { } } -// Host user avatars + team logos on the ZPan Cloud avatar service. Requires the -// instance to be paired to Cloud (an active license binding with a refresh token); -// an unbound instance can't host images, so upload returns `cloud_required` (503) -// and delete is a best-effort no-op. Never throws on the unbound path. +// Host user avatars + team logos. On Cloudflare with a `PUBLIC_IMAGES` R2 binding, uploads go +// straight to that bucket and are served from this instance (or an R2 custom domain). +// Without the binding (Node/Docker, or a Worker without it) it falls back to the ZPan +// Cloud avatar service, which requires an active license binding — an unbound instance +// then returns `cloud_required` (503) and delete is a best-effort no-op. export function createImageUploadGateway( licenseBinding: LicenseBindingRepo, licensingCloud: LicensingCloudGateway, @@ -70,6 +94,42 @@ export function createImageUploadGateway( return platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT } + async function r2Upload( + bucket: R2BucketLike, + platform: Platform, + scope: AvatarScope, + id: string, + file: File, + contentType: string, + ): Promise { + const buffer = await file.arrayBuffer() + const key = avatarKey(scope, id) + await bucket.put(key, buffer, { httpMetadata: { contentType } }) + return { ok: true, url: r2AvatarUrl(platform, key, await contentVersion(buffer)) } + } + + async function cloudUpload( + platform: Platform, + scope: AvatarScope, + id: string, + file: File, + contentType: AvatarContentType, + ): Promise { + const binding = await licenseBinding.loadActiveLicenseBinding() + if (!binding?.refreshToken) return { ok: false, status: 503, error: 'cloud_required' } + + const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken) + try { + const res = await uploadAvatar(client, { scope, id, body: file, contentType }) + if (!res.ok) return cloudUploadError(res) + const parsed = avatarUploadResponseSchema.safeParse(await res.json()) + if (!parsed.success) return { ok: false, status: 500, error: 'invalid_cloud_response' } + return { ok: true, url: parsed.data.url } + } catch { + return { ok: false, status: 500, error: 'cloud_request_failed' } + } + } + return { async uploadPublicImage(platform, prefix, id, file): Promise { const contentType = file.type @@ -80,31 +140,32 @@ export function createImageUploadGateway( return { ok: false, status: 413, error: 'File too large. Max 1 MiB.' } } - const binding = await licenseBinding.loadActiveLicenseBinding() - if (!binding?.refreshToken) return { ok: false, status: 503, error: 'cloud_required' } - - const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken) - try { - const res = await uploadAvatar(client, { scope: prefixToScope(prefix), id, body: file, contentType }) - if (!res.ok) return cloudUploadError(res) - const parsed = avatarUploadResponseSchema.safeParse(await res.json()) - if (!parsed.success) return { ok: false, status: 500, error: 'invalid_cloud_response' } - return { ok: true, url: parsed.data.url } - } catch { - return { ok: false, status: 500, error: 'cloud_request_failed' } - } + const scope = prefixToScope(prefix) + const bucket = platform.getBinding(PUBLIC_IMAGES_BINDING) + if (bucket) return r2Upload(bucket, platform, scope, id, file, contentType) + return cloudUpload(platform, scope, id, file, contentType) }, - // Best-effort delete of the Cloud-hosted image. DB clearing is the caller's - // responsibility — this only removes the object from the Cloud avatar service. - // An unbound instance has nothing to delete, so it is a silent no-op. + // Best-effort delete of the hosted image. DB clearing is the caller's responsibility — + // this only removes the object from R2 (CF) or the Cloud avatar service (fallback). async deletePublicImageVariants(platform, prefix, id): Promise { + const scope = prefixToScope(prefix) + + const bucket = platform.getBinding(PUBLIC_IMAGES_BINDING) + if (bucket) { + try { + await bucket.delete(avatarKey(scope, id)) + } catch (err) { + console.warn('[image-upload] r2 avatar delete skipped:', err) + } + return + } + const binding = await licenseBinding.loadActiveLicenseBinding() if (!binding?.refreshToken) return - const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken) try { - await deleteAvatar(client, { scope: prefixToScope(prefix), id }) + await deleteAvatar(client, { scope, id }) } catch (err) { console.warn('[image-upload] cloud avatar delete skipped:', err) } diff --git a/server/app.ts b/server/app.ts index db3c3068..b4ffafa1 100644 --- a/server/app.ts +++ b/server/app.ts @@ -5,6 +5,7 @@ import type { Context } from 'hono' import { cors } from 'hono/cors' import type { Auth } from './auth' import { createDeps } from './composition' +import { serveAvatarBlob } from './http/avatar-blobs' import backgroundJobs from './http/background-jobs' import downloadTasks from './http/downloads/download-tasks' import downloaders, { downloaderSelfRoute } from './http/downloads/downloaders' @@ -177,6 +178,8 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep // /r/* is listed separately in run_worker_first. // /s/:token is intentionally left for the SPA landing page. app.route('/api/shares', publicShares) + // Self-hosted avatar blobs (CF + AVATARS R2 binding, no AVATARS_PUBLIC_URL). Public. + app.get('/api/avatar-blobs/:scope/:id', serveAvatarBlob) app.route('/r', redirect) app.route('/api/teams', publicTeams) app.route('/api/site/auth-providers', authProviders) diff --git a/server/http/avatar-blobs.ts b/server/http/avatar-blobs.ts new file mode 100644 index 00000000..08c3ad8c --- /dev/null +++ b/server/http/avatar-blobs.ts @@ -0,0 +1,20 @@ +import type { Context } from 'hono' +import type { Env } from '../middleware/platform' +import { PUBLIC_IMAGES_BINDING, type R2BucketLike } from '../platform/interface' + +// Public read of a self-hosted avatar blob from the PUBLIC_IMAGES R2 binding. Only used on +// Cloudflare when the binding is present and PUBLIC_IMAGES_URL is NOT set (e.g. local +// miniflare, which gives R2 no public URL); with a custom domain set, or on Node/Docker +// (Cloud avatar service), the stored URL is absolute and this route is never hit. +export async function serveAvatarBlob(c: Context) { + const bucket = c.get('platform').getBinding(PUBLIC_IMAGES_BINDING) + if (!bucket) return c.body(null, 404) + + const obj = await bucket.get(`${c.req.param('scope')}/${c.req.param('id')}`) + if (!obj) return c.body(null, 404) + + return c.body(await obj.arrayBuffer(), 200, { + 'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000, immutable', + }) +} diff --git a/server/platform/interface.ts b/server/platform/interface.ts index 0aba5f34..30b29063 100644 --- a/server/platform/interface.ts +++ b/server/platform/interface.ts @@ -16,3 +16,23 @@ export interface Platform { // on the return value to pick a runtime-appropriate code path. getBinding(key: string): T | undefined } + +// R2 bucket binding for self-hosted avatar storage. Present on Cloudflare when the +// `PUBLIC_IMAGES` binding is configured; absent on Node/Docker (callers then fall back to the +// Cloud avatar service). +export const PUBLIC_IMAGES_BINDING = 'PUBLIC_IMAGES' + +// Minimal R2 surface we use — typed locally so non-CF builds don't need workers-types. +export interface R2ObjectBodyLike { + arrayBuffer(): Promise + httpMetadata?: { contentType?: string } +} +export interface R2BucketLike { + put( + key: string, + value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob, + options?: { httpMetadata?: { contentType?: string } }, + ): Promise + get(key: string): Promise + delete(key: string): Promise +} diff --git a/src/components/layout/user-account-menu.tsx b/src/components/layout/user-account-menu.tsx index 142a6439..fdbae496 100644 --- a/src/components/layout/user-account-menu.tsx +++ b/src/components/layout/user-account-menu.tsx @@ -43,7 +43,7 @@ export function UserAccountMenu({ - {user?.image && } + {user ? getInitials(user.name || user.username || '?') : '?'} diff --git a/src/routes/_authenticated/settings/profile.tsx b/src/routes/_authenticated/settings/profile.tsx index 65c26874..af54d035 100644 --- a/src/routes/_authenticated/settings/profile.tsx +++ b/src/routes/_authenticated/settings/profile.tsx @@ -27,10 +27,16 @@ const profileSchema = z.object({ type ProfileFormValues = z.infer -// Refresh the session so useSession() sees DB changes made outside -// better-auth.updateUser (e.g. avatar commit / delete). +// Sync useSession() with avatar changes written outside better-auth (our /users/me/avatar +// endpoint). Two steps are required: +// 1. getSession({ disableCookieCache }) re-reads user.image from the DB and refreshes the +// 5-min session cookie cache — without it the cached session keeps the old avatar. +// 2. $store.notify('$sessionSignal') is what actually re-renders: useSession() only refetches +// when better-auth's session signal toggles, and an external endpoint never toggles it, +// so without this the new avatar shows only after a full page reload. async function refreshSession() { - await authClient.getSession() + await authClient.getSession({ query: { disableCookieCache: true } }) + authClient.$store.notify('$sessionSignal') } function AvatarCard() { @@ -92,7 +98,9 @@ function AvatarCard() { className="group relative flex-shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - {user?.image && } + {/* Always render so radix re-runs its loading status when the avatar is removed + (src -> undefined) and falls back to the initials instead of going blank. */} + {getInitials(displayName)} diff --git a/wrangler.toml b/wrangler.toml index 24df60da..4276c790 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -27,6 +27,13 @@ max_batch_size = 1 max_batch_timeout = 1 max_retries = 3 +# Self-hosted avatar storage. When present, avatars upload straight to R2 (served from +# /api/avatar-blobs, or from PUBLIC_IMAGES_URL if an R2 custom domain is configured) +# instead of the ZPan Cloud avatar service. +[[r2_buckets]] +binding = "PUBLIC_IMAGES" +bucket_name = "zpan-public-images" + [observability] enabled = true @@ -51,6 +58,10 @@ database_name = "zpan-db-staging" database_id = "a9197d7a-6524-42f0-b646-e714dcb30b3b" migrations_dir = "./migrations" +[[env.staging.r2_buckets]] +binding = "PUBLIC_IMAGES" +bucket_name = "zpan-public-images-staging" + [[env.staging.send_email]] name = "EMAIL"