From 8d1ffa9ab55d2008f1167fce2c886ccfcebb571c Mon Sep 17 00:00:00 2001 From: philipcheung Date: Sat, 12 Sep 2026 09:58:29 +0800 Subject: [PATCH] Add R2 upload and media serving endpoints --- src/pages/admin/upload.ts | 60 +++++++++++++++++++++++++++++++++++++ src/pages/media/[...key].ts | 24 +++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/pages/admin/upload.ts create mode 100644 src/pages/media/[...key].ts diff --git a/src/pages/admin/upload.ts b/src/pages/admin/upload.ts new file mode 100644 index 0000000..b63c258 --- /dev/null +++ b/src/pages/admin/upload.ts @@ -0,0 +1,60 @@ +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) }); +}; diff --git a/src/pages/media/[...key].ts b/src/pages/media/[...key].ts new file mode 100644 index 0000000..8d3b4fb --- /dev/null +++ b/src/pages/media/[...key].ts @@ -0,0 +1,24 @@ +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 }); +};