mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
4f47a81e5b
On Cloudflare Workers deployments with PUBLIC_IMAGES (R2 binding) +
PUBLIC_IMAGES_URL (env) configured, writes go through the R2 binding
directly — zero auth overhead, zero egress cost, no AWS SDK in the hot
path. Reads are served straight from R2's managed public domain; no
Worker round-trip per image.
Non-CF deployments (Node/Docker/Lambda/Vercel/etc.) keep the existing
behavior: select the DB-configured mode='public' storage row and use
S3Service. The only requirement is that users still add a public
storage via Admin → Storages.
`uploadPublicImage(platform, prefix, id, file)` and
`deletePublicImageVariants(platform, prefix, id)` now take a Platform
rather than a bare Database. They internally pick the backend:
getBackend:
if getBinding('PUBLIC_IMAGES') && getEnv('PUBLIC_IMAGES_URL')
→ R2 backend
else if selectStorage(db, 'public') succeeds
→ S3 backend
else
→ none (returns 503)
The R2Bucket type is declared locally (minimal structural shape) so we
avoid pulling @cloudflare/workers-types into non-CF builds.
12 new unit tests in image-upload.test.ts cover both backends —
mime/size validation, URL construction (including trailing-slash
normalization + jpeg→jpg extension), delete-all-variants, and the
fallback precedence matrix (binding alone / URL alone / neither →
expected result).
Callers updated:
- server/routes/me.ts — PUT/DELETE /avatar
- server/routes/teams.ts — PUT/DELETE /:teamId/logo
Existing integration tests (54 cases across me + teams) continue to
pass via the S3 fallback path — they use mockPlatform without a binding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import type { Storage as S3Storage } from '../../shared/types'
|
|
import type { Platform } from '../platform/interface'
|
|
import { S3Service } from './s3'
|
|
import { selectStorage } from './storage'
|
|
|
|
const s3 = new S3Service()
|
|
|
|
export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'] as const
|
|
export type ImageMime = (typeof IMAGE_MIMES)[number]
|
|
export const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2 MiB
|
|
|
|
const MIME_TO_EXT: Record<ImageMime, string> = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/webp': 'webp',
|
|
}
|
|
|
|
export function isImageMime(v: unknown): v is ImageMime {
|
|
return typeof v === 'string' && (IMAGE_MIMES as readonly string[]).includes(v)
|
|
}
|
|
|
|
export function imageKey(prefix: string, id: string, mime: ImageMime): string {
|
|
return `${prefix}/${id}.${MIME_TO_EXT[mime]}`
|
|
}
|
|
|
|
export type ImageUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string }
|
|
|
|
// Minimal R2Bucket interface we actually call — typed locally so we don't
|
|
// depend on @cloudflare/workers-types on non-CF builds.
|
|
interface R2BucketLike {
|
|
put(
|
|
key: string,
|
|
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
|
|
options?: { httpMetadata?: { contentType?: string } },
|
|
): Promise<unknown>
|
|
delete(key: string): Promise<void>
|
|
}
|
|
|
|
type Backend =
|
|
| { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string }
|
|
| { kind: 's3'; storage: S3Storage }
|
|
| { kind: 'none' }
|
|
|
|
// CF deployment with `PUBLIC_IMAGES` binding + `PUBLIC_IMAGES_URL` env var →
|
|
// writes via R2 binding (zero-auth, zero-egress), reads via R2's public
|
|
// domain (direct browser fetch, no Worker round-trip per image).
|
|
// Everything else → falls back to the user-configured `mode='public'` S3
|
|
// storage in the `storages` table.
|
|
async function getBackend(platform: Platform): Promise<Backend> {
|
|
const r2 = platform.getBinding<R2BucketLike>('PUBLIC_IMAGES')
|
|
const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL')
|
|
if (r2 && publicUrl) {
|
|
return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') }
|
|
}
|
|
try {
|
|
const storage = (await selectStorage(platform.db, 'public')) as unknown as S3Storage
|
|
return { kind: 's3', storage }
|
|
} catch {
|
|
return { kind: 'none' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stream-proxy a File to the workspace's public image backend and return the
|
|
* permanent public URL. Shared by /api/me/avatar and /api/teams/:id/logo —
|
|
* validates mime + size, selects backend (R2 binding on CF / S3 fallback),
|
|
* constructs the key as `<prefix>/<id>.<ext>`, PUTs, returns URL.
|
|
*/
|
|
export async function uploadPublicImage(
|
|
platform: Platform,
|
|
prefix: string,
|
|
id: string,
|
|
file: File,
|
|
): Promise<ImageUploadResult> {
|
|
if (!isImageMime(file.type)) {
|
|
return { ok: false, status: 400, error: 'Only PNG, JPG, and WebP images are allowed' }
|
|
}
|
|
if (file.size > MAX_IMAGE_SIZE) {
|
|
return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' }
|
|
}
|
|
|
|
const backend = await getBackend(platform)
|
|
if (backend.kind === 'none') {
|
|
return { ok: false, status: 503, error: 'No public storage configured' }
|
|
}
|
|
|
|
const key = imageKey(prefix, id, file.type)
|
|
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
|
|
if (backend.kind === 'r2') {
|
|
await backend.bucket.put(key, bytes, { httpMetadata: { contentType: file.type } })
|
|
return { ok: true, url: `${backend.publicUrlBase}/${key}` }
|
|
}
|
|
|
|
await s3.putObject(backend.storage, key, bytes, file.type)
|
|
return { ok: true, url: s3.getPublicUrl(backend.storage, key) }
|
|
}
|
|
|
|
/**
|
|
* Best-effort delete of every mime variant of a public image. DB clearing is
|
|
* the caller's responsibility — this only touches the backend object store.
|
|
*/
|
|
export async function deletePublicImageVariants(platform: Platform, prefix: string, id: string): Promise<void> {
|
|
const backend = await getBackend(platform)
|
|
if (backend.kind === 'none') return
|
|
|
|
if (backend.kind === 'r2') {
|
|
await Promise.allSettled(IMAGE_MIMES.map((mime) => backend.bucket.delete(imageKey(prefix, id, mime))))
|
|
return
|
|
}
|
|
|
|
try {
|
|
await Promise.allSettled(IMAGE_MIMES.map((mime) => s3.deleteObject(backend.storage, imageKey(prefix, id, mime))))
|
|
} catch (err) {
|
|
console.warn('[image-upload] S3 cleanup skipped:', err)
|
|
}
|
|
}
|