Files
yingfungsolar/docs/superpowers/specs/2026-09-12-r2-image-upload-design.md
T

9.2 KiB
Raw Blame History

後台圖片上傳(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. 目標與非目標

目標

  1. 4 個圖片欄位全部可以由「揀檔即時上傳」取代手動貼 URL,同時保留貼 URL 能力(progressive enhancement)。
  2. 圖片存喺 R2,由同一個 Worker 經 /media/* 路由送出,唔需要公開 bucket 或額外網域。
  3. 上傳時喺瀏覽器端縮圖(最大寬 1600px)並轉 WebP(約 85% 質素)先上傳,零伺服器成本、零新依賴。
  4. 換圖或刪除內容時,自動刪除對應舊 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.mjsplatformProxy 讀 bindingR2 物件存喺 .wrangler/state(已 gitignore)。

src/lib/env.tsAppEnvMEDIA: R2Bucket

2.2 上傳 endpoint — /admin/upload

src/pages/admin/upload.tsexport const prerender = falsePOST only

  • middleware.ts 既有 /admin 保護(未登入導向 login)。
  • multipart/form-datafileFile)、scopesettings | cases | posts)。
  • 驗證:
    • 類型只准 image/webpimage/jpegimage/pngimage/gif拒絕 SVG(避免 XSS)。
    • 大小上限 8MB(client 已縮圖,正常遠低於此)。
    • scope 唔合法 → 400。
  • Key<scope>/<crypto.randomUUID()>.<ext>ext 由實際 MIME type 決定(image/webpwebpimage/jpegjpgimage/pngpngimage/gifgif)。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].tsexport const prerender = falseGET

  • const key = Astro.params.keyrest param)。
  • const obj = await getEnv().MEDIA.get(key)null → 404。
  • Header
    • Content-Typeobj.httpMetadata?.contentType ?? "application/octet-stream"
    • Cache-Control: public, max-age=31536000, immutablekey 含 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(上傳中 / ✓ 已上傳 / 錯誤)。

Propsnamelabelvaluescopeid

共用 script:寫喺 AdminLayout.astro(全站後台載入一次),偵測 [data-image-field]

  1. change 事件:createImageBitmap(file) → 計 scale = min(1, 1600 / width) → 畫落 canvas → canvas.toBlob(..., "image/webp", 0.85)
  2. toBlob 回 null(瀏覽器唔支援 WebP)→ fallback 用原檔。
  3. fetch POST /admin/uploadFormDatafile + scope)。
  4. 成功 → 將 url 寫入 URL 欄、更新預覽 src、顯示「✓ 已上傳」。
  5. 失敗 → 顯示錯誤訊息,保留原 URL 欄值。
  6. 若 JS 失效 → 用戶仍可貼 URL,主表單照常運作。

主表單不變settings.astro / cases.astro / post/[id].astro 繼續 parseForm + Zod,只係收到 /media/... 字串。

OG imageBase.astro 已會將相對 image 轉絕對 URLnew URL(image, origin)),所以 og_image / coverImage/media/... 冇問題。


3. 欄位與設定

src/data/settings-fields.ts

  • FieldType"image"
  • hero_imageog_imagetype: "url" 改為 type: "image"

src/pages/admin/settings.astro

  • image 型別 render ImageFieldscope settings);textarea / text / url 維持原本。

src/pages/admin/cases.astro:新增表單同每張卡嘅圖片欄改用 ImageFieldscope cases)。

src/pages/admin/post/[id].astrocoverImage 欄改用 ImageFieldscope 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_bucketsbinding MEDIA
astro.config.mjs 唔使改(platformProxy 已讀 wrangler bindings
src/lib/env.ts AppEnvMEDIA: 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 FieldTypeimagehero/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.tsenv.tsMEDIA
  • src/data/AGENTS.mdFieldTypeimage
  • AGENTS.mdR2 binding、r2:createMEDIA

7. 驗證

冇 test / lint scriptnpm run build 係唯一 build 驗證。

手動(npm run dev :4321,需先 npm run r2:create 或靠本機模擬 R2):

  1. 4 個位各上傳一張圖 → URL 欄自動填 /media/...、預覽正常顯示。
  2. 直接開 /media/<key> → 出圖、有 Cache-ControlETag;亂打 key → 404。
  3. 換圖後 → 舊 /media/... 變 404(已刪)。
  4. 刪除案例 / 文章 → 對應圖變 404。
  5. 貼外部 URL(唔上傳)→ 照樣儲存同顯示。
  6. 上傳 SVG / 超大檔 → 顯示中文錯誤、唔會寫入。
  7. 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。