# R2 Image Upload Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 後台 4 個圖片欄位(`hero_image`、`og_image`、案例 `imageUrl`、文章 `coverImage`)支援揀檔即時上傳去 Cloudflare R2,同時保留貼 URL。 **Architecture:** 單一 Worker 加一個 R2 binding `MEDIA`;`/admin/upload`(受 middleware 保護)收檔寫入 R2,`/media/*` 公開讀出。瀏覽器端用 Canvas 縮到最大寬 1600px、轉 WebP 再上傳。舊圖喺換圖/刪除時由 `src/lib/media.ts` 的 `deleteMedia` 清理。**唔改 DB schema、唔加 migration。** **Tech Stack:** Astro 7(`output: "static"` + per-page SSR)、Cloudflare Workers + R2、Drizzle/D1、Zod、原生 Canvas/fetch(後台唔引入 React)。 **Spec:** `docs/superpowers/specs/2026-09-12-r2-image-upload-design.md` **重要:呢個專案冇 test / lint script。每個 task 的驗證係 `npm run build`(唯一 build 驗證)+ 有需要時 `npm run dev` 手動測。冇單元測試框架,所以步驟用 build + 手動檢查取代 TDD。** --- ## File Structure | 檔案 | 責任 | |---|---| | `wrangler.jsonc` | R2 binding(`MEDIA`) | | `package.json` | `r2:create` script | | `scripts/setup.mjs` | 一鍵 setup 建 R2 bucket | | `src/lib/env.ts` | `AppEnv.MEDIA` | | `src/lib/media.ts`(新) | `/media/` prefix、URL↔key、`deleteMedia` | | `src/pages/admin/upload.ts`(新) | 上傳 endpoint | | `src/pages/media/[...key].ts`(新) | 出圖 endpoint | | `src/components/admin/ImageField.astro`(新) | 圖片欄元件(URL 欄 + file input + 預覽 + 狀態) | | `src/layouts/AdminLayout.astro` | client 端縮圖/上傳 script + image field 樣式 | | `src/data/settings-fields.ts` | `FieldType` 加 `image`;hero/og 改型別 | | `src/pages/admin/settings.astro` | image 型別用 ImageField + 換圖刪舊 | | `src/pages/admin/cases.astro` | ImageField + 換圖/刪除清理 | | `src/pages/admin/post/[id].astro` | ImageField + 換封面/刪文清理 | **唔郁:** `src/db/schema.ts`、`migrations/**`、`src/schemas/*`(圖片仍係字串)。 --- ### Task 1: R2 binding、env 型別同 setup 指令 **Files:** - Modify: `wrangler.jsonc` - Modify: `package.json` - Modify: `scripts/setup.mjs` - Modify: `src/lib/env.ts` - [ ] **Step 1: `wrangler.jsonc` 加 R2 binding** 喺 `"kv_namespaces": [...]` 之後(結尾 `]` 加逗號)加: ```jsonc "r2_buckets": [ { "binding": "MEDIA", "bucket_name": "yingfung-solar-media" } ] ``` 完整 `wrangler.jsonc` 應該係: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "yingfung-solar", "main": "@astrojs/cloudflare/entrypoints/server", "compatibility_date": "2026-09-10", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "./dist", "binding": "ASSETS", "not_found_handling": "404-page" }, "observability": { "enabled": true, "logs": { "enabled": true } }, "vars": { "SITE_NAME": "盈豐太陽能" }, "d1_databases": [ { "binding": "DB", "database_name": "yingfung-solar-db", "database_id": "PASTE_D1_DATABASE_ID_HERE" } ], "kv_namespaces": [ { "binding": "CACHE", "id": "PASTE_KV_NAMESPACE_ID_HERE" } ], "r2_buckets": [ { "binding": "MEDIA", "bucket_name": "yingfung-solar-media" } ] } ``` - [ ] **Step 2: `package.json` 加 `r2:create`** 喺 `"kv:create"` 之後加一行: ```json "kv:create": "wrangler kv namespace create CACHE", "r2:create": "wrangler r2 bucket create yingfung-solar-media", ``` - [ ] **Step 3: `src/lib/env.ts` 加 `MEDIA`** `AppEnv` 改成: ```ts export type AppEnv = { DB: D1Database; CACHE: KVNamespace; MEDIA: R2Bucket; ADMIN_PASSWORD?: string; AI_API_KEY?: string; TAVILY_API_KEY?: string; }; ``` - [ ] **Step 4: `scripts/setup.mjs` 加建 R2 bucket 步驟** 喺檔案上方 constant 區(`const KV_PLACEHOLDER = ...` 之後)加: ```js const R2_BUCKET = "yingfung-solar-media"; ``` 喺 KV 區塊(`} else { console.log("KV id 已設定,略過。"); }`)之後、`const setPassword = await rl.question(...)` 之前加: ```js console.log(`檢查 / 建立 R2 bucket「${R2_BUCKET}」…`); const r2List = runSoft("npx wrangler r2 bucket list"); const r2Text = `${r2List.out || ""}\n${r2List.err || ""}`; if (new RegExp(`\\b${R2_BUCKET}\\b`).test(r2Text)) { console.log(" R2 bucket 已存在,略過。"); } else { const r2 = runSoft(`npx wrangler r2 bucket create ${R2_BUCKET}`); if (r2.ok) { console.log(" R2 bucket 已建立。"); } else { console.log(" 建立 R2 bucket 失敗(可能未開通 R2),請稍後手動執行: npm run r2:create"); console.log(r2.out || r2.err || ""); } } ``` - [ ] **Step 5: 重新產生 binding 型別** Run: `npm run types` Expected: 成功,`worker-configuration.d.ts` 內 `MEDIA: R2Bucket;` 出現喺 `Env`。 Run: `rg -n "MEDIA: R2Bucket" worker-configuration.d.ts` Expected: 有一行 match(若 `wrangler types` 讀唔到 R2 binding,手動加都唔會影響 build,但正常會出)。 - [ ] **Step 6: Build 驗證** Run: `npm run build` Expected: 完成,冇 error。 - [ ] **Step 7: Commit** ```bash git add wrangler.jsonc package.json scripts/setup.mjs src/lib/env.ts worker-configuration.d.ts git commit -m "Add R2 MEDIA binding and setup script" ``` --- ### Task 2: `src/lib/media.ts` 媒體工具 **Files:** - Create: `src/lib/media.ts` - [ ] **Step 1: 建立 `src/lib/media.ts`** ```ts import type { AppEnv } from "./env"; export const MEDIA_PREFIX = "/media/"; /** 由 R2 key 砌出對外 URL。 */ export function mediaUrl(key: string): string { return `${MEDIA_PREFIX}${key}`; } /** 由 /media/... URL 取出 R2 key;唔係 /media/ 開頭回 null。 */ export function mediaKey(url: string | null | undefined): string | null { if (!url || !url.startsWith(MEDIA_PREFIX)) return null; return url.slice(MEDIA_PREFIX.length); } /** 刪除 /media/... 對應嘅 R2 物件;外部 URL 或空值唔理,唔會 throw。 */ export async function deleteMedia( env: AppEnv, url: string | null | undefined, ): Promise { const key = mediaKey(url); if (!key) return; try { await env.MEDIA.delete(key); } catch { // 刪除失敗唔應該阻礙儲存流程。 } } ``` - [ ] **Step 2: Build 驗證** Run: `npm run build` Expected: 完成,冇 error。 - [ ] **Step 3: Commit** ```bash git add src/lib/media.ts git commit -m "Add media helper for R2 keys and cleanup" ``` --- ### Task 3: 上傳同出圖 endpoints **Files:** - Create: `src/pages/admin/upload.ts` - Create: `src/pages/media/[...key].ts` - [ ] **Step 1: 建立 `src/pages/admin/upload.ts`** ```ts import type { APIRoute } from "astro"; import { getEnv } from "../../lib/env"; import { mediaUrl } from "../../lib/media"; export const prerender = false; const SCOPES = new Set(["settings", "cases", "posts"]); const ALLOWED: Record = { "image/webp": "webp", "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", }; const MAX_BYTES = 8 * 1024 * 1024; function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } export const POST: APIRoute = async ({ request }) => { let form: FormData; try { form = await request.formData(); } catch { return json({ ok: false, error: "無法讀取上傳內容。" }, 400); } const file = form.get("file"); const scope = String(form.get("scope") ?? ""); if (!(file instanceof File) || file.size === 0) { return json({ ok: false, error: "請揀選圖片檔案。" }, 400); } if (!SCOPES.has(scope)) { return json({ ok: false, error: "上傳目標唔正確。" }, 400); } const ext = ALLOWED[file.type]; if (!ext) { return json({ ok: false, error: "只支援 JPG / PNG / WebP / GIF 圖片。" }, 400); } if (file.size > MAX_BYTES) { return json({ ok: false, error: "圖片太大(上限 8MB)。" }, 400); } const key = `${scope}/${crypto.randomUUID()}.${ext}`; try { await getEnv().MEDIA.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: file.type }, }); } catch { return json({ ok: false, error: "上傳失敗,請稍後再試。" }, 500); } return json({ ok: true, url: mediaUrl(key) }); }; ``` - [ ] **Step 2: 建立 `src/pages/media/[...key].ts`** ```ts import type { APIRoute } from "astro"; import { getEnv } from "../../lib/env"; export const prerender = false; export const GET: APIRoute = async ({ params, request }) => { const key = params.key ?? ""; if (!key) return new Response("Not found", { status: 404 }); const obj = await getEnv().MEDIA.get(key); if (!obj) return new Response("Not found", { status: 404 }); const etag = obj.httpEtag; if (etag && request.headers.get("if-none-match") === etag) { return new Response(null, { status: 304, headers: { ETag: etag } }); } const headers = new Headers(); headers.set("Content-Type", obj.httpMetadata?.contentType ?? "application/octet-stream"); headers.set("Cache-Control", "public, max-age=31536000, immutable"); if (etag) headers.set("ETag", etag); return new Response(obj.body, { headers }); }; ``` - [ ] **Step 3: Build 驗證** Run: `npm run build` Expected: 完成,冇 error,而且 `dist/` 唔會 build 出 `/media` 靜態頁(endpoint 係 SSR)。 - [ ] **Step 4: 手動測試 endpoints** Run: `npm run dev`(另一個 terminal) Expected: 1. 開 `http://localhost:4321/media/nope.webp` → 404。 2. 登入 `http://localhost:4321/admin` 後(需要 `.dev.vars` 有 `ADMIN_PASSWORD`),用瀏覽器 DevTools console 執行(本機 R2 係 wrangler 模擬): ```js const fd = new FormData(); fd.append("file", new File([new Uint8Array([82,73,70,70])], "x.webp", { type: "image/webp" })); fd.append("scope", "cases"); console.log(await (await fetch("/admin/upload", { method: "POST", body: fd })).json()); ``` Expected: `{ ok: true, url: "/media/cases/.webp" }`,跟住開嗰個 url 有 response(body 係嗰 4 bytes)。 - [ ] **Step 5: Commit** ```bash git add src/pages/admin/upload.ts "src/pages/media/[...key].ts" git commit -m "Add R2 upload and media serving endpoints" ``` --- ### Task 4: `ImageField` 元件同後台 client script **Files:** - Create: `src/components/admin/ImageField.astro` - Modify: `src/layouts/AdminLayout.astro` - [ ] **Step 1: 建立 `src/components/admin/ImageField.astro`** ```astro --- interface Props { name: string; label: string; value?: string | null; scope: "settings" | "cases" | "posts"; id?: string; } const { name, label, value = "", scope, id = name } = Astro.props; ---
``` - [ ] **Step 2: `AdminLayout.astro` 加 client script** 喺 `` 之後、`` 之前加: ```astro ``` - [ ] **Step 3: `AdminLayout.astro` 加樣式** 喺 `