mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
feat(avatar): self-host avatars on R2 on Workers + fix avatar refresh/fallback (#468)
* 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 && <AvatarImage>}`
unmounts the radix Image, which keeps a stale "loaded" status so the Fallback
stays hidden. Always render <AvatarImage src={user?.image ?? undefined}> so radix
re-runs its loading status (src -> undefined => "error") and shows the initials.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
00f48cf355
commit
f24c6e2021
@@ -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-<hash>.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
|
||||
|
||||
|
||||
@@ -9,8 +9,28 @@ type Any = any
|
||||
const AVATAR_PREFIX = '_system/avatars'
|
||||
const LOGO_PREFIX = '_system/org-logos'
|
||||
|
||||
function mockPlatform(env: Record<string, string | undefined> = {}): Platform {
|
||||
return { db: {} as Any, getEnv: (k: string) => env[k], getBinding: () => undefined } as unknown as Platform
|
||||
function mockPlatform(env: Record<string, string | undefined> = {}, 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<string, { body: ArrayBuffer; contentType?: string }>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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<ImageUploadResult> {
|
||||
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<ImageUploadResult> {
|
||||
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<ImageUploadResult> {
|
||||
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<R2BucketLike>(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<void> {
|
||||
const scope = prefixToScope(prefix)
|
||||
|
||||
const bucket = platform.getBinding<R2BucketLike>(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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Env>) {
|
||||
const bucket = c.get('platform').getBinding<R2BucketLike>(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',
|
||||
})
|
||||
}
|
||||
@@ -16,3 +16,23 @@ export interface Platform {
|
||||
// on the return value to pick a runtime-appropriate code path.
|
||||
getBinding<T = unknown>(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<ArrayBuffer>
|
||||
httpMetadata?: { contentType?: string }
|
||||
}
|
||||
export interface R2BucketLike {
|
||||
put(
|
||||
key: string,
|
||||
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
|
||||
options?: { httpMetadata?: { contentType?: string } },
|
||||
): Promise<unknown>
|
||||
get(key: string): Promise<R2ObjectBodyLike | null>
|
||||
delete(key: string): Promise<void>
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function UserAccountMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="flex-1 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<Avatar size="sm">
|
||||
{user?.image && <AvatarImage src={user.image} alt={user.name || user.username || ''} />}
|
||||
<AvatarImage src={user?.image ?? undefined} alt={user?.name || user?.username || ''} />
|
||||
<AvatarFallback className="bg-sidebar-primary text-sidebar-primary-foreground text-xs font-semibold">
|
||||
{user ? getInitials(user.name || user.username || '?') : '?'}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -27,10 +27,16 @@ const profileSchema = z.object({
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>
|
||||
|
||||
// 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"
|
||||
>
|
||||
<Avatar className="size-20 border">
|
||||
{user?.image && <AvatarImage src={user.image} alt={displayName} />}
|
||||
{/* 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. */}
|
||||
<AvatarImage src={user?.image ?? undefined} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">{getInitials(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user