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

206 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 後台圖片上傳(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` 加:
```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` 讀 bindingR2 物件存喺 `.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]`
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/upload``FormData``file` + `scope`)。
4. 成功 → 將 `url` 寫入 URL 欄、更新預覽 `src`、顯示「✓ 已上傳」。
5. 失敗 → 顯示錯誤訊息,保留原 URL 欄值。
6. 若 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`scope `settings`);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):
1. 4 個位各上傳一張圖 → URL 欄自動填 `/media/...`、預覽正常顯示。
2. 直接開 `/media/<key>` → 出圖、有 `Cache-Control``ETag`;亂打 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。