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:
Jasper Van
2026-06-20 02:05:09 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 00f48cf355
commit f24c6e2021
9 changed files with 315 additions and 30 deletions
+57
View File
@@ -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
+107 -2
View File
@@ -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()
})
})
+84 -23
View File
@@ -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)
}
+3
View File
@@ -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)
+20
View File
@@ -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',
})
}
+20
View File
@@ -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>
}
+1 -1
View File
@@ -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>
+12 -4
View File
@@ -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">
+11
View File
@@ -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"