206 lines
9.2 KiB
Markdown
206 lines
9.2 KiB
Markdown
# 後台圖片上傳(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` 讀 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]`:
|
||
|
||
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。
|