9.2 KiB
後台圖片上傳(Cloudflare R2)(Design Spec)
- 日期:2026-09-12
- 狀態:已與客戶確認方向
- 背景:後台有 4 個圖片欄位,現時一律要手動貼外部圖片 URL(
網站設定的hero_image/og_image、完成案例的imageUrl、文章的coverImage)。客戶想可以直接上傳,圖片存喺 Cloudflare R2。 - 相關:
2026-09-11-structure-ai-deploy-design.md(單一 Worker + D1 + KV 架構)依然有效。本 spec 新增 R2 binding 同兩個 endpoint。
1. 目標與非目標
目標
- 4 個圖片欄位全部可以由「揀檔即時上傳」取代手動貼 URL,同時保留貼 URL 能力(progressive enhancement)。
- 圖片存喺 R2,由同一個 Worker 經
/media/*路由送出,唔需要公開 bucket 或額外網域。 - 上傳時喺瀏覽器端縮圖(最大寬 1600px)並轉 WebP(約 85% 質素)先上傳,零伺服器成本、零新依賴。
- 換圖或刪除內容時,自動刪除對應舊 R2 檔。
非目標 (Out of scope)
- 唔支援 Blog 內文(Markdown)插圖上傳。
- 唔改 DB schema、唔加 migration(圖片欄位本身已係 text)。
- 唔做伺服器端圖片處理(WASM / Cloudflare Images),唔引入 React / Chakra 落後台。
- 唔做獨立媒體庫頁、圖片裁切、alt text 管理。
- 冇圖片版本控制 / 歷史。
2. 架構
2.1 R2 binding
wrangler.jsonc 加:
"r2_buckets": [
{ "binding": "MEDIA", "bucket_name": "yingfung-solar-media" }
]
同 D1 / KV 唔同,R2 binding 用固定 bucket_name,冇 id placeholder,唔使 setup.mjs 寫 id。本機靠現有 astro.config.mjs 的 platformProxy 讀 binding,R2 物件存喺 .wrangler/state(已 gitignore)。
src/lib/env.ts 的 AppEnv 加 MEDIA: R2Bucket。
2.2 上傳 endpoint — /admin/upload
src/pages/admin/upload.ts,export const prerender = false,POST only:
- 由
middleware.ts既有/admin保護(未登入導向 login)。 - 收
multipart/form-data:file(File)、scope(settings|cases|posts)。 - 驗證:
- 類型只准
image/webp、image/jpeg、image/png、image/gif;拒絕 SVG(避免 XSS)。 - 大小上限 8MB(client 已縮圖,正常遠低於此)。
scope唔合法 → 400。
- 類型只准
- Key:
<scope>/<crypto.randomUUID()>.<ext>,ext由實際 MIME type 決定(image/webp→webp、image/jpeg→jpg、image/png→png、image/gif→gif)。client 正常會轉 webp,故多數係.webp。 await env.MEDIA.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: file.type } })。- 回
{ ok: true, url: "/media/<key>" };失敗回{ ok: false, error: "<中文訊息>" }(HTTP 400/500),唔 throw。
2.3 出圖 endpoint — /media/[...key]
src/pages/media/[...key].ts,export const prerender = false,GET:
const key = Astro.params.key(rest param)。const obj = await getEnv().MEDIA.get(key);null→ 404。- Header:
Content-Type:obj.httpMetadata?.contentType ?? "application/octet-stream"。Cache-Control: public, max-age=31536000, immutable(key 含 uuid,內容不可變)。ETag:用obj.httpEtag;若 request 帶If-None-Match相同 → 回 304。
- 回
new Response(obj.body, { headers })。
/media/* 係公開(前台圖片要讀);只有上傳要認證。
2.4 Client 端增強
共用元件 src/components/admin/ImageField.astro(新資料夾 src/components/admin/)render:
- 一個
type="text"欄(原本嘅 URL 欄,保留貼 URL)。 - 一個
type="file" accept="image/*"。 - 一個
<img>預覽(有值先顯示)。 - 一個狀態文字 span(上傳中 / ✓ 已上傳 / 錯誤)。
Props:name、label、value、scope、id。
共用 script:寫喺 AdminLayout.astro(全站後台載入一次),偵測 [data-image-field]:
change事件:createImageBitmap(file)→ 計 scale =min(1, 1600 / width)→ 畫落 canvas →canvas.toBlob(..., "image/webp", 0.85)。toBlob回 null(瀏覽器唔支援 WebP)→ fallback 用原檔。fetch POST /admin/upload(FormData:file+scope)。- 成功 → 將
url寫入 URL 欄、更新預覽src、顯示「✓ 已上傳」。 - 失敗 → 顯示錯誤訊息,保留原 URL 欄值。
- 若 JS 失效 → 用戶仍可貼 URL,主表單照常運作。
主表單不變:settings.astro / cases.astro / post/[id].astro 繼續 parseForm + Zod,只係收到 /media/... 字串。
OG image:Base.astro 已會將相對 image 轉絕對 URL(new URL(image, origin)),所以 og_image / coverImage 用 /media/... 冇問題。
3. 欄位與設定
src/data/settings-fields.ts:
FieldType加"image"。hero_image、og_image由type: "url"改為type: "image"。
src/pages/admin/settings.astro:
- image 型別 render
ImageField(scopesettings);textarea / text / url 維持原本。
src/pages/admin/cases.astro:新增表單同每張卡嘅圖片欄改用 ImageField(scope cases)。
src/pages/admin/post/[id].astro:coverImage 欄改用 ImageField(scope posts)。
4. 自動刪除舊圖
新 src/lib/media.ts:
MEDIA_PREFIX = "/media/";mediaUrl(key)→/media/<key>。deleteMedia(env, url):只當url以/media/開頭才env.MEDIA.delete(key);外部 URL、空值一律唔理,唔 throw。
呼叫位置:
| 檔案 | 時機 |
|---|---|
cases.astro |
save:比對 DB 舊 imageUrl 同新值,唔同先刪舊。delete:刪除該案例圖片。 |
post/[id].astro |
save:比對 existing.coverImage 同新值。delete:刪除封面。 |
settings.astro |
每個 image key upsert 前攞舊值,唔同先刪舊。 |
cases.astro 目前 update 前冇 select,需要先攞現有 row 做比對。
已知取捨
- 同一個
/media/...URL 若被貼去多過一個欄位,刪其中一個會令另一個爛圖。單一管理員情境可接受,spec 標明。 - 舊值若係外部 URL(唔係
/media/)唔會刪。
5. 設定 / 部署改動
| 檔案 | 改動 |
|---|---|
wrangler.jsonc |
加 r2_buckets(binding MEDIA) |
astro.config.mjs |
唔使改(platformProxy 已讀 wrangler bindings) |
src/lib/env.ts |
AppEnv 加 MEDIA: R2Bucket |
package.json |
加 "r2:create": "wrangler r2 bucket create yingfung-solar-media" |
scripts/setup.mjs |
加建立 R2 bucket 步驟(idempotent,已存在就略過),令一鍵 setup 完整 |
.dev.vars.example |
唔使改(R2 係 binding,唔係 secret) |
worker-configuration.d.ts |
跑 npm run types 重新產生 |
6. 檔案改動總表
| 檔案 | 改動 |
|---|---|
wrangler.jsonc |
加 R2 binding |
package.json |
加 r2:create |
scripts/setup.mjs |
建 R2 bucket |
src/lib/env.ts |
AppEnv.MEDIA |
src/lib/media.ts |
新:MEDIA_PREFIX / mediaUrl / deleteMedia |
src/pages/admin/upload.ts |
新:上傳 endpoint |
src/pages/media/[...key].ts |
新:出圖 endpoint |
src/components/admin/ImageField.astro |
新:圖片欄元件 |
src/layouts/AdminLayout.astro |
加 client 端縮圖/上傳 script + image field 樣式 |
src/pages/admin/settings.astro |
image 型別用 ImageField + 換圖刪舊 |
src/pages/admin/cases.astro |
ImageField + 換圖/刪除清理 |
src/pages/admin/post/[id].astro |
ImageField + 換封面/刪文清理 |
src/data/settings-fields.ts |
FieldType 加 image;hero/og 改型別 |
src/schemas/* |
唔使改(URL 仍係 string) |
src/db/schema.ts / migrations/ |
唔使改 |
DOX 更新
src/AGENTS.md:Admin「圖片一律 URL(暫無上傳)」改為描述上傳流程;加/admin/upload、/media/*endpoint。src/lib/AGENTS.md:加media.ts;env.ts加MEDIA。src/data/AGENTS.md:FieldType加image。- 根
AGENTS.md:R2 binding、r2:create、MEDIA。
7. 驗證
冇 test / lint script;npm run build 係唯一 build 驗證。
手動(npm run dev :4321,需先 npm run r2:create 或靠本機模擬 R2):
- 4 個位各上傳一張圖 → URL 欄自動填
/media/...、預覽正常顯示。 - 直接開
/media/<key>→ 出圖、有Cache-Control同ETag;亂打 key → 404。 - 換圖後 → 舊
/media/...變 404(已刪)。 - 刪除案例 / 文章 → 對應圖變 404。
- 貼外部 URL(唔上傳)→ 照樣儲存同顯示。
- 上傳 SVG / 超大檔 → 顯示中文錯誤、唔會寫入。
npm run build成功。
8. 決策記錄
- Worker 路由
/media/*而唔用公開 R2 網域:唔使改 Cloudflare 設定、本機 dev 一致、可自控 cache header。代價係圖片經 Worker request(小型官網可接受)。 - 瀏覽器端轉 WebP:免費、唔食 Worker CPU(免費 plan 10ms/request 限制)、零依賴。代價係依賴瀏覽器(單一管理員用現代瀏覽器,且保留 fallback)。
- 保留貼 URL:漸進增強,JS 失效都可用,亦兼容外部圖。
- 自動刪舊圖:客戶選擇;用 uuid key 避免誤刪同名檔。
- 拒絕 SVG:避免 inline SVG XSS。