閱讀約 22 分鐘 教學, Astro, Cloudflare Pages, Backblaze B2, GitHub OAuth, Sveltia CMS, 個人網站
從零打造個人網站+私人雲端:Astro × Cloudflare Pages × Backblaze B2 × 自架 OAuth 完整教學
一篇可以照著做出來的部落格文。從 npm create astro 起手到自架 GitHub OAuth 認證上線,每個步驟附完整指令、配置與程式碼。
這篇文章在教什麼
帶你從完全空的資料夾開始,一步一步搭出一個「公開履歷網站 + 登入後才能用的私人雲端」二合一的個人站。月費 0 元、不綁信用卡、域名隨你套。
你會用到的東西:Astro 5(靜態網站框架)、Cloudflare Pages(託管 + Serverless Functions)、Backblaze B2(S3 相容物件儲存)、GitHub OAuth(登入機制)、Sveltia CMS(瀏覽器後台寫文章)。
全文約 20 分鐘讀完。建議邊讀邊跟著做,撞牆時跳到第 8 章「踩坑全紀錄」找對照。
1. 我為什麼自己蓋而不用現成方案
我要的不只是「個人網站」,是同一個域名、同時做兩件事:
/、/portfolio、/blog、/resume、/about→ 對外公開,給人資、合作對象看/vault、/admin、/api/cloud/*→ 只有我登入後能用,存草稿、敏感檔案、後台寫文章
而且要符合三個硬條件:
- 月費 0 元:碩士生不想多一筆固定支出
- 不綁信用卡:怕忘了取消被扣款
- 每篇文章用一個 boolean 控制公私:寫稿時不想搬資料夾、改路徑
來看一下現成方案為什麼不夠:
| 方案 | 缺點 |
|---|---|
| Notion 公開頁 | 私密頁要付費;每篇公私切換不夠細;SEO 慢 |
| WordPress | 要自己防駭、外觀俗、plugin 一多就吃資源 |
| GitHub Pages + 私 repo | repo 整個私密可以,但單一頁面加密做不到 |
| Carrd / Wix 等 builder | 漂亮但完全沒有「私人雲端」這塊 |
| Nextcloud | 當網路硬碟可以,做履歷網站很醜 |
沒有一個剛好。所以決定自己搭。下面是最終 stack。
2. 技術選型(每項都附「為什麼不選 X」)
2.1 前端框架:Astro 5
比較項 Astro 5 Next.js Hugo Jekyll
----------- ---------- ------------ ----------- -------------
適用場景 內容站 App + 內容 純內容 純內容
JS payload ~0 KB ~80 KB+ 0 KB 0 KB
寫法 .astro .tsx Go template Liquid
TS 支援 原生 原生 無 無
學習曲線 淺 中-深 淺 淺
選 Astro 因為:純靜態輸出,預設 0 JS,要互動才掛 island;Markdown / MDX 內建;Content Collections 用 Zod 強型別 frontmatter。
不選 Next.js 因為:個人站不需要 SSR/RSC,用 Next 多扛幾百 KB 的 React runtime 划不來。
不選 Hugo / Jekyll 因為:Go template / Liquid 比 JSX/Astro syntax 笨重,且生態系沒那麼新。
2.2 託管:Cloudflare Pages
比較項 Cloudflare Pages Vercel Netlify
-------------------- ------------------ ----------- ------------
免費流量 無限 100 GB/月 100 GB/月
免費 build 次數 500/月 6000 min/月 300 min/月
Serverless Functions ✓ Pages Functions ✓ ✓
邊緣節點 全球 300+ 全球 全球
綁信用卡才能用? 否 否 否
選 Cloudflare 因為:流量無上限是個人站最大的安心牌;免費 build 量也夠用(一般一天最多 push 幾次)。
不選 Vercel 因為:流量超量會自動往 Pro 升級,對「絕不被收費」這條紅線不安全。
2.3 物件儲存:Backblaze B2
比較項 Backblaze B2 Cloudflare R2 AWS S3
-------------- ---------------- ----------------- ----------
免費容量 10 GB 10 GB 5 GB
是否要綁卡 否 是 是
S3 相容 ✓ ✓ ✓ (原生)
免費下載 1 GB/天 無限 1 GB/月
選 B2 因為:寫這篇時我去開 Cloudflare R2,發現 dashboard 強制要求綁信用卡才能啟用(即使選 free tier),不符合我的硬條件。B2 完全不要求。
權衡:B2 每天只給 1 GB 免費下載。對個人雲端用途(不對外分享),這量夠用一輩子。
2.4 內容後台:Sveltia CMS
選 Sveltia 因為:純前端 SPA、git-based(直接寫 Markdown 進 repo)、不需要自架後端 DB。對手 Decap CMS 已經停止維護,Tina CMS 要付費 SaaS。
2.5 認證:自架 GitHub OAuth + JWT cookie
最後決定的版本。第一版用 Cloudflare Access Email OTP,撞了一連串問題(OTP 收不到、Cloudflare Zero Trust 改版選單漂移)後砍掉重練。詳見第 6 章。
3. 從 0 開始:建專案骨架
3.1 起手式
# 1. 建專案,選 minimal template(不要 starter blog,內容會打架)
npm create astro@latest personal-site -- --template minimal --typescript strict --install --no-git
cd personal-site
# 2. 加 Tailwind v4
npm install -D tailwindcss @tailwindcss/vite
這一步跑完,npm run dev 會在 http://localhost:4321 啟動空白頁面。
3.2 目錄結構(最終版)
personal-site/
├─ astro.config.mjs # Astro + Tailwind v4 設定
├─ functions/ # Cloudflare Pages Functions(Serverless)
│ ├─ _middleware.ts # 路由攔截 → /vault, /admin, /api/cloud
│ ├─ lib/auth.ts # JWT sign/verify、cookie helpers
│ └─ api/
│ ├─ auth/ # login.ts、callback.ts、logout.ts、whoami.ts
│ └─ cloud/[[path]].ts # B2 物件儲存 API
├─ public/
│ ├─ _headers # CSP、HSTS、X-Frame-Options
│ └─ admin/ # Sveltia CMS(純前端)
│ ├─ index.html
│ └─ config.yml
├─ src/
│ ├─ content.config.ts # Content Collections schema
│ ├─ components/ # Header.astro、Footer.astro、ThemeToggle.astro
│ ├─ content/ # Markdown 內容
│ │ ├─ posts/*.md # 文章
│ │ ├─ works/*.md # 作品
│ │ ├─ photos/*.md # 照片
│ │ └─ resume/*.md # 履歷段落
│ ├─ layouts/Base.astro
│ ├─ lib/content.ts # getPublic / getPrivate helpers
│ ├─ pages/ # 路由
│ └─ site.config.ts # 站台 SSoT 設定
├─ workers/sveltia-cms-auth/ # Sveltia OAuth proxy Worker
└─ wrangler.toml # Cloudflare 設定
3.3 Content Collections schema
整站「公私切換」的核心是一個 public boolean。在 src/content.config.ts:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const visibility = {
public: z.boolean().default(false), // ← 改 true 就出現在公開區
draft: z.boolean().default(false),
};
const posts = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
...visibility,
}),
});
export const collections = { posts /* , works, photos, resume */ };
於是任何 markdown 只要寫:
---
title: "我的論文草稿"
public: false # 私密:只有 /vault 看得到
---
就完成歸類。不要把過濾邏輯散布在頁面,集中在一個 helper。
3.4 公私過濾 helper
src/lib/content.ts:
import { getCollection, type CollectionKey, type CollectionEntry } from "astro:content";
/** 公開區用 */
export async function getPublic<C extends CollectionKey>(name: C) {
const all = await getCollection(name);
return all
.filter((e) => e.data.public === true && e.data.draft !== true)
.sort((a, b) => +b.data.date - +a.data.date);
}
/** /vault 私密區用 */
export async function getPrivate<C extends CollectionKey>(name: C) {
const all = await getCollection(name);
return all
.filter((e) => e.data.public === false || e.data.public === undefined)
.sort((a, b) => +b.data.date - +a.data.date);
}
單一資料源讓「不小心把私密文章誤公開」幾乎不可能發生——只能透過改 public: true 才會出現在公開頁。
4. 公開頁實作 + 部署上線
4.1 寫一個首頁
src/pages/index.astro:
---
import Base from "@/layouts/Base.astro";
import { getPublic, getFeatured } from "@/lib/content";
const recentPosts = (await getPublic("posts")).slice(0, 3);
const featuredWorks = await getFeatured("works", 3);
---
<Base title="首頁">
<section class="hero">
<h1>哈囉,我是 XXX</h1>
<p>研究生/全端工程師,正在做 RAG 評估的論文。</p>
</section>
<section>
<h2>最近文章</h2>
<ul>
{recentPosts.map((p) => (
<li><a href={`/blog/${p.id}`}>{p.data.title}</a></li>
))}
</ul>
</section>
</Base>
4.2 推到 GitHub
git init
git add .
git commit -m "init: astro skeleton + content collections"
gh repo create personal-site --public --source=. --push
4.3 接到 Cloudflare Pages
預期看到的畫面:到
https://dash.cloudflare.com→ 左側 Workers & Pages → Create → Pages → Connect to Git。授權 GitHub 後選personal-siterepo,build 設定填:
- Framework preset: Astro
- Build command:
npm run build- Build output directory:
dist- Root directory:
/按 Save and Deploy,1-2 分鐘後拿到
https://personal-site-XXX.pages.dev。
往後 git push 自動觸發 deploy。從 commit 到上線約 30-50 秒。
5. 加上 /vault 私密區(先不管登入)
5.1 Backblaze B2 設定
到 https://www.backblaze.com/b2/ 註冊(不用信用卡)。
步驟:
- Buckets → Create a Bucket
- Name:
yian524-vault-files(必須全球唯一) - Files in Bucket are:Private
- Default Encryption:Enable(用 B2 Managed Keys)
- Name:
- 記下 bucket info 顯示的 Endpoint:
https://s3.us-east-005.backblazeb2.com - App Keys → Add a New Application Key
- Name:
personal-site-pages-function - Allow access to Bucket(s):只勾剛才建的 bucket
- Type of Access:Read and Write
- 按 Create New Key
- Name:
- 這頁顯示的 keyID 和 applicationKey 只會出現一次——複製到密碼管理器,馬上設到 Cloudflare Pages 環境變數(步驟 5.4)
5.2 用 aws4fetch 寫 B2 API
B2 是 S3 相容,需要 SigV4 簽章。在 Pages Functions 環境用 aws4fetch(幾 KB 的純 ESM 套件)。
npm install aws4fetch
functions/api/cloud/[[path]].ts(重點段,完整檔在 repo functions/api/cloud/[[path]].ts):
import { AwsClient } from "aws4fetch";
interface Env {
B2_ENDPOINT?: string; // https://s3.us-east-005.backblazeb2.com
B2_BUCKET?: string; // yian524-vault-files
B2_REGION?: string; // us-east-005
B2_KEY_ID?: string;
B2_APPLICATION_KEY?: string;
}
function getS3(env: Env) {
return new AwsClient({
accessKeyId: env.B2_KEY_ID!,
secretAccessKey: env.B2_APPLICATION_KEY!,
service: "s3",
region: env.B2_REGION!,
});
}
export const onRequest: PagesFunction<Env> = async ({ request, env, params }) => {
const s3 = getS3(env);
const segments = (params.path as string[]) ?? [];
const [verb, ...rest] = segments;
const url = new URL(request.url);
// ---- LIST ----
if (verb === "list" && request.method === "GET") {
const prefix = url.searchParams.get("prefix") ?? "";
const listUrl = new URL(`${env.B2_ENDPOINT}/${env.B2_BUCKET}`);
listUrl.searchParams.set("list-type", "2");
if (prefix) listUrl.searchParams.set("prefix", prefix);
const res = await s3.fetch(listUrl.toString());
const xml = await res.text();
// 解析 XML(略,見完整檔)
return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}
// ---- PUT ----
if (verb === "put" && request.method === "PUT") {
const key = rest.join("/");
const objectUrl = `${env.B2_ENDPOINT}/${env.B2_BUCKET}/${encodeURI(key)}`;
const res = await s3.fetch(objectUrl, {
method: "PUT",
body: request.body,
headers: { "Content-Type": "application/octet-stream" },
});
return Response.json({ ok: res.ok, status: res.status });
}
// ---- GET / DELETE ... 略 ----
return new Response("Method or path not supported", { status: 405 });
};
5.3 安全強化(很重要不要省)
// 1. CSRF:PUT/DELETE 必須 same-origin
function originAllowed(req: Request): boolean {
const origin = req.headers.get("Origin");
if (!origin) return false;
try {
return new URL(origin).host === new URL(req.url).host;
} catch { return false; }
}
// 2. 防 stored XSS:GET 強制 attachment 下載
const headers = new Headers();
headers.set("Content-Type", "application/octet-stream");
headers.set("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
headers.set("X-Content-Type-Options", "nosniff");
// 3. 軟刪除:copy 到 _trash/ 再 delete 原檔,30 天內可救回
const trashKey = `_trash/${Date.now()}-${key}`;
await s3.fetch(`${env.B2_ENDPOINT}/${env.B2_BUCKET}/${encodeURI(trashKey)}`, {
method: "PUT",
headers: { "x-amz-copy-source": `/${env.B2_BUCKET}/${encodeURI(key)}` },
});
await s3.fetch(`${env.B2_ENDPOINT}/${env.B2_BUCKET}/${encodeURI(key)}`, { method: "DELETE" });
// 4. key 白名單:只允許 [A-Za-z0-9._-/],禁止 .. 和 _trash/
const KEY_RE = /^[A-Za-z0-9._\-/]+$/;
5.4 設環境變數
到 Cloudflare Pages → 你的 project → Settings → Environment variables → Production,新增 5 條(B2_APPLICATION_KEY 一定要勾 Encrypt):
B2_ENDPOINT https://s3.us-east-005.backblazeb2.com
B2_BUCKET yian524-vault-files
B2_REGION us-east-005
B2_KEY_ID <貼剛才複製的 keyID>
B2_APPLICATION_KEY <貼剛才複製的 applicationKey> ← 勾 Encrypt
存檔後重新觸發一次 deploy 才會生效(直接到 Deployments → 最新一次 → Retry deployment)。
5.5 寫 /vault 前端頁
src/pages/vault/index.astro 是個普通靜態頁,重點是 fetch /api/cloud/list 拿檔案清單:
const res = await fetch("/api/cloud/list", { credentials: "include" });
const { objects } = await res.json();
// 渲染 objects[i].key、size、uploaded
上傳:
async function upload(file: File) {
await fetch(`/api/cloud/put/${encodeURIComponent(file.name)}`, {
method: "PUT",
body: file,
credentials: "include",
});
}
到此 /vault 沒有任何登入保護——任何訪客都能列檔上傳。下一章補上認證。
6. 認證系統(最大一章,砍掉重練的故事)
6.1 第一版:Cloudflare Access(為什麼放棄)
最初的計畫是用 Cloudflare Access:在 edge 設一條規則「/vault/*、/admin/*、/api/cloud/* 必須通過 Email OTP」,配上 Pages Function 內的 JWT 二次驗證做 defense-in-depth。
聽起來完美。實作時撞到三件事:
- Cloudflare Zero Trust 改版——「Login methods」設定埋在三層子選單裡,官方文件跟實際 dashboard 路徑對不上
- OTP 驗證碼信被 Gmail 過濾——測試三次都收不到
- OTP 用過一次立刻 expire——重新觸發要等 cooldown
debug 兩小時無解後決定砍掉,全部自架。經驗:把核心安全綁在「dashboard 設定」上,dashboard 改版時你就死了。
6.2 第二版:自架 GitHub OAuth + JWT cookie
新架構流程圖:
使用者打 /vault
↓
[functions/_middleware.ts]
├─ 沒 cookie → 302 /api/auth/login → GitHub authorize
│ ↓
│ <使用者按 Authorize> ↓
│ GitHub 302 回 /api/auth/callback?code=...&state=...
│ ↓
│ [callback.ts] 驗 state → 換 access_token → 取 user.login
│ → 比對白名單 → 簽 JWT → set cookie → 302 回原路
↓
有 cookie → verify JWT → 通過
↓
[/vault/index.html or /api/cloud/list]
6.3 建 GitHub OAuth App
到 https://github.com/settings/developers → OAuth Apps → New OAuth App:
Application name: Personal Site Vault
Homepage URL: https://personal-site-XXX.pages.dev
Authorization callback: https://personal-site-XXX.pages.dev/api/auth/callback
Register application → Generate a new client secret → 馬上複製(這頁刷新後 secret 就看不到了)。
6.4 寫 JWT 工具
functions/lib/auth.ts(重點段):
const COOKIE_NAME = "vault_session";
const SESSION_TTL = 7 * 24 * 60 * 60; // 7 days
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
export async function signJwt(claims: { login: string; exp: number }, secret: string) {
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = b64url(JSON.stringify(claims));
const data = `${header}.${payload}`;
const key = await hmacKey(secret);
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
const sig = b64urlBytes(new Uint8Array(sigBuf));
return `${data}.${sig}`;
}
export async function verifyJwt(token: string, secret: string) {
const [h, p, s] = token.split(".");
if (!h || !p || !s) return null;
const header = JSON.parse(atob(h.replace(/-/g, "+").replace(/_/g, "/")));
if (header.alg !== "HS256" || header.typ !== "JWT") return null;
const key = await hmacKey(secret);
const ok = await crypto.subtle.verify("HMAC", key, decodeB64Url(s), new TextEncoder().encode(`${h}.${p}`));
if (!ok) return null;
const claims = JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/")));
if (claims.exp < Math.floor(Date.now() / 1000)) return null;
return claims;
}
關鍵安全點:verifyJwt 內顯式比對 header.alg === "HS256",避免 alg confusion 攻擊(攻擊者塞 alg: none)。
6.5 寫 OAuth login / callback
functions/api/auth/login.ts:
export const onRequestGet: PagesFunction<AuthEnv> = async ({ request, env }) => {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/vault";
const state = crypto.randomUUID(); // CSRF 防護
const ghAuth = new URL("https://github.com/login/oauth/authorize");
ghAuth.searchParams.set("client_id", env.VAULT_OAUTH_CLIENT_ID!);
ghAuth.searchParams.set("redirect_uri", `${url.origin}/api/auth/callback`);
ghAuth.searchParams.set("scope", "read:user");
ghAuth.searchParams.set("state", state);
const headers = new Headers();
headers.append("Set-Cookie", setCookie("vault_oauth_state", state, 600));
headers.append("Set-Cookie", setCookie("vault_redirect_to", redirectTo, 600));
headers.set("Location", ghAuth.toString());
return new Response(null, { status: 302, headers });
};
functions/api/auth/callback.ts:
export const onRequestGet: PagesFunction<AuthEnv> = async ({ request, env }) => {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const stateCookie = readCookie(request, "vault_oauth_state");
if (!code || !state || stateCookie !== state) {
return new Response("State mismatch", { status: 403 });
}
// 1. code 換 access_token
const tokRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
client_id: env.VAULT_OAUTH_CLIENT_ID,
client_secret: env.VAULT_OAUTH_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokRes.json();
// 2. 取 user.login
const userRes = await fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${access_token}`, "User-Agent": "personal-site" },
});
const user = await userRes.json();
// 3. 比對白名單
const allowed = (env.VAULT_ALLOWED_LOGINS ?? "").split(",").map(s => s.trim().toLowerCase());
if (!allowed.includes(user.login.toLowerCase())) {
return new Response(`User '${user.login}' not authorized`, { status: 403 });
}
// 4. 簽 JWT、set cookie
const exp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;
const token = await signJwt({ login: user.login, exp }, env.VAULT_JWT_SECRET!);
const headers = new Headers();
headers.append("Set-Cookie", setCookie("vault_session", token, 7 * 24 * 60 * 60));
headers.set("Location", readCookie(request, "vault_redirect_to") ?? "/vault");
return new Response(null, { status: 302, headers });
};
6.6 寫 middleware 把守路由
functions/_middleware.ts:
import { getSession, isAllowed, type AuthEnv } from "./lib/auth";
const PROTECTED = ["/vault", "/admin", "/api/cloud"];
function isProtected(p: string) {
if (p.startsWith("/api/auth/") || p === "/api/health") return false;
return PROTECTED.some(prefix => p === prefix || p.startsWith(prefix + "/"));
}
export const onRequest: PagesFunction<AuthEnv> = async (ctx) => {
const { request, env, next } = ctx;
const url = new URL(request.url);
if (!isProtected(url.pathname)) return next();
// 環境變數沒設 = fail closed
if (!env.VAULT_OAUTH_CLIENT_ID || !env.VAULT_JWT_SECRET || !env.VAULT_ALLOWED_LOGINS) {
return new Response("Server misconfigured", { status: 503 });
}
const session = await getSession(request, env);
if (!session || !isAllowed(session.login, env)) {
const back = url.pathname + url.search;
return Response.redirect(`/api/auth/login?redirect_to=${encodeURIComponent(back)}`, 302);
}
return next(); // ← 不要傳 modified Request!詳見第 8 章踩坑 4
};
6.7 設 4 條 vault 環境變數
VAULT_OAUTH_CLIENT_ID <GitHub OAuth Client ID>
VAULT_OAUTH_CLIENT_SECRET <GitHub OAuth Client Secret> ← 勾 Encrypt
VAULT_JWT_SECRET <openssl rand -hex 32 產的隨機> ← 勾 Encrypt
VAULT_ALLOWED_LOGINS yian524 (多人用 , 分隔)
設完 → Retry deployment → 測試。打開無痕視窗訪問 /vault:
預期流程:
- 跳到 GitHub「Authorize Personal Site Vault」頁
- 按 Authorize
- 跳回
/vault,看到檔案列表
7. Sveltia CMS 後台(在 /admin)
7.1 部署 OAuth Worker
Sveltia 是純前端 SPA,但 GitHub OAuth code-for-token 交換必須在後端做(要 client secret)。所以另外開一個 Worker。
mkdir -p workers/sveltia-cms-auth/src
cd workers/sveltia-cms-auth
npm init -y
npm install -D wrangler
workers/sveltia-cms-auth/wrangler.toml:
name = "sveltia-cms-auth"
main = "src/index.ts"
compatibility_date = "2025-09-01"
[vars]
ALLOWED_DOMAINS = "personal-site-XXX.pages.dev,localhost:4321"
src/index.ts 簡化版(完整檔在 repo):
export default {
async fetch(req: Request, env: { GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; ALLOWED_DOMAINS: string }) {
const url = new URL(req.url);
// /auth: 重導去 GitHub authorize
if (url.pathname === "/auth") {
const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
authorizeUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID);
authorizeUrl.searchParams.set("scope", "repo,user");
return Response.redirect(authorizeUrl.toString(), 302);
}
// /callback: 換 token → 用 postMessage 回傳給開啟的視窗
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
const tokRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, client_secret: env.GITHUB_CLIENT_SECRET, code }),
});
const { access_token } = await tokRes.json();
return new Response(`<script>
window.opener.postMessage({ type:"authorization:github:success", token:"${access_token}" }, "*");
window.close();
</script>`, { headers: { "Content-Type": "text/html" } });
}
return new Response("not found", { status: 404 });
}
};
部署:
npx wrangler login # 開瀏覽器授權,跑一次就好
npx wrangler deploy # 拿到 https://sveltia-cms-auth.<account>.workers.dev
npx wrangler secret put GITHUB_CLIENT_ID # 互動式貼 ID
npx wrangler secret put GITHUB_CLIENT_SECRET # 互動式貼 secret
7.2 設 Sveltia CMS
public/admin/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>網站後台・Sveltia CMS</title>
</head>
<body>
<script
type="module"
src="https://unpkg.com/@sveltia/cms@0.158.1/dist/sveltia-cms.js"
integrity="sha384-f6B8ZEIijAwLpP+bKP0y0BG6Llc/faM2soiixXBiCu9CO2i+uXClS1OPJEwhCsRQ"
crossorigin="anonymous"
></script>
</body>
</html>
為什麼要 SRI:unpkg.com 是 CDN,理論上有人下毒就完蛋。SRI hash 鎖死「載到的內容跟我寫文章那天一致」,不對就拒絕執行。
SRI 怎麼算:
curl -s https://unpkg.com/@sveltia/cms@0.158.1/dist/sveltia-cms.js \ | openssl dgst -sha384 -binary | openssl base64 -A
public/admin/config.yml:
backend:
name: github
repo: yian524/personal-site
branch: main
base_url: https://sveltia-cms-auth.<account>.workers.dev
media_folder: src/content/_uploads
public_folder: /uploads
collections:
- name: posts
folder: src/content/posts
create: true
fields:
- { label: 標題, name: title, widget: string }
- { label: 描述, name: description, widget: text }
- { label: 日期, name: date, widget: datetime }
- { label: 公開, name: public, widget: boolean, default: false }
- { label: 內文, name: body, widget: markdown }
7.3 加個彩蛋:logo 連點 6 下進後台
寫好 /admin 但不放在 nav 裡——對訪客沒價值,反而暴露攻擊面。但每次寫文章都要打網址也很煩。
折衷做法是老派彩蛋:logo 連點 6 下跳 /admin。在 Header.astro 結尾加:
<script is:inline>
(() => {
const brand = document.querySelector(".brand");
if (!brand) return;
let clicks = [];
let pendingNav = null;
brand.addEventListener("click", (e) => {
e.preventDefault();
const now = Date.now();
clicks = clicks.filter((t) => now - t < 1500);
clicks.push(now);
if (clicks.length >= 6) {
clicks = [];
window.location.href = "/admin";
return;
}
if (pendingNav) clearTimeout(pendingNav);
pendingNav = setTimeout(() => { window.location.href = "/"; }, 250);
});
})();
</script>
訪客點 logo 仍回首頁(多 250ms 延遲,無感);我連點 6 下就跳後台。原始碼公開但路徑零視覺暗示。
8. 踩坑全紀錄(症狀 → 排查 → 根因 → 修法)
這 5 個是我實際撞過的坑,每個都附完整 debug 過程。如果你照著做也撞到,跳到對應段落。
踩坑 1:dependabot 升 Astro 6 → build 直接爆
症狀:合掉 dependabot PR 後 Cloudflare Pages deploy 失敗:
[vite] error during build:
Missing field `tsconfigPaths` on BindingViteResolvePluginConfig
排查:
git log --oneline -5 # 找到合 PR 那次 commit 是 35d3734
git show 35d3734 # 看到 astro 5.x → 6.x 跟一堆相關 deps
根因:Astro 6 的 Vite 從 esbuild 切到 Rolldown,@tailwindcss/vite 還沒適配新的 resolve API。
修法:
git revert 35d3734
git push
長期解:在 .github/dependabot.yml 鎖住主版本:
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule: { interval: weekly }
ignore:
- dependency-name: "astro"
update-types: ["version-update:semver-major"]
- dependency-name: "@astrojs/*"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]
教訓:dependabot 自動 PR 可以,但主版本升級永遠手動。
踩坑 2:CSP 多條規則交集 → Sveltia 永遠載不出來
症狀:/admin 打開後 console 噴:
Refused to load the script 'https://unpkg.com/@sveltia/cms@0.158.1/...'
because it violates the following Content Security Policy directive:
"script-src 'self' 'unsafe-inline'"
但我明明在 _headers 寫了 /admin/* 允許 unpkg:
/admin/*
Content-Security-Policy: script-src 'self' 'unsafe-inline' https://unpkg.com
排查:用 curl 看實際發出的 header:
curl -I https://personal-site-XXX.pages.dev/admin/
# Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
# Content-Security-Policy: script-src 'self' 'unsafe-inline' https://unpkg.com
兩條 CSP header 同時出現。
根因:Cloudflare Pages 的 _headers 對於符合多條規則的路徑會疊加 emit,瀏覽器收到多條 CSP header 時會強制執行交集(最嚴格的勝)。/* 那條沒允許 unpkg → 交集後就是不允許。
修法:整併成一條全域 CSP,把所有需要的來源都加進 allow-list:
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https: https://fonts.gstatic.com; connect-src 'self' https://api.github.com https://*.workers.dev https://unpkg.com https://fonts.googleapis.com https://fonts.gstatic.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
教訓:別用 _headers 的 per-path CSP override 想做「/admin 寬鬆、其他嚴格」,會反過來變成「全部最嚴格」。要嘛統一寬鬆+SRI 鎖死,要嘛上 nonce-based CSP。
踩坑 3:_redirects 跟 Cloudflare 自動 308 互打 → 全站 redirect loop
症狀:所有頁面都回 ERR_TOO_MANY_REDIRECTS:
curl -I https://personal-site-XXX.pages.dev/portfolio
# HTTP/2 308
# location: /portfolio/
curl -I https://personal-site-XXX.pages.dev/portfolio/
# HTTP/2 301
# location: /portfolio
# ...無限循環
排查:用 curl --max-redirs 20 -L 跟到底:
curl -L --max-redirs 20 https://personal-site-XXX.pages.dev/portfolio
# 20 次後仍未 200,停在 redirect chain
根因:我寫了 public/_redirects 想把 /foo/ 規範化成 /foo:
/portfolio/ /portfolio 301
但 Cloudflare Pages 對「目錄型」靜態檔案會自動 308 加上斜線(/portfolio → /portfolio/)。我的 301 又把斜線去掉,CF 又加回來,無限循環。
修法:刪掉整個 _redirects 檔。
rm public/_redirects
git commit -am "fix: remove _redirects, trust Cloudflare auto-trailing-slash"
git push
教訓:在 Cloudflare 上信任預設斜線行為,不要自己寫去斜線規則。如果一定要去斜線,要在 astro.config.mjs 設 trailingSlash: 'never' 並讓 build 直接吐沒斜線的 URL,而不是用 redirect 修。
踩坑 4:middleware 用 next(modifiedRequest) 觸發無窮 middleware
症狀:認證寫完,/api/auth/whoami 回 {authenticated: true},但 /vault 還是 redirect loop。
排查:把 middleware 印 log 看:
console.log(`[mw] ${url.pathname} cookies=${request.headers.get('Cookie')?.length}`);
看到同一個 request 進 middleware 兩次——第一次帶 cookie 通過,第二次 cookie 不見了又被擋。
根因:原本的 middleware 是這樣寫:
// ❌ 錯誤寫法
const upstream = new Request(request);
upstream.headers.set("X-Vault-User-Login", session.login);
return next(upstream);
問題:Cloudflare Pages 的 next(modifiedRequest) 會重新進入 middleware chain,且 new Request(original) 在某些情況下不保留 Cookie header。第二次進 middleware 時 cookie 沒了,被當未登入又 302。
修法:
// ✅ 正確寫法
return next(); // 不傳參數
下游 function 自己再讀一次 cookie:
// functions/api/cloud/[[path]].ts
const session = await getSession(request, env); // 直接讀 cookie
教訓:Cloudflare Pages 的 middleware 盡量不要傳 modified Request 給 next()。要傳資料給下游就用 ctx.data 或 ctx.waitUntil,不要透過 Request header。
踩坑 5:Secret 不小心貼進 chat 視窗
症狀:跟 AI 助手協作時,第一次順手把 B2 master key 貼出來;發現後撤銷重產,第二次又貼了新產的 key;產第三把後我終於停下來想清楚。
排查:不是 bug,是流程問題。
根因:當系統提示「我把 secret 顯示給你看」時,Ctrl+C → Ctrl+V 進對話框是肌肉記憶。
修法:建立單向流動規則:secret 只能從來源(B2 dashboard、GitHub OAuth secret 頁、openssl rand)直接貼到目標環境變數欄位——
# ✅ 正確:用 wrangler 互動式輸入,stdin 不會留紀錄
npx wrangler secret put GITHUB_CLIENT_SECRET
# (跳出 prompt, 你貼進去, Enter)
# ✅ 正確:Cloudflare Pages dashboard 設定 Encrypted env var
# (在瀏覽器內貼,不經過任何 chat / terminal history)
# ❌ 錯誤:在任何聊天視窗顯示 secret
# ❌ 錯誤:echo $SECRET 到 terminal
# ❌ 錯誤:截圖含 secret 的畫面
如果 secret 已經洩漏:立刻撤銷產新的,2 分鐘的事,不要僥倖。
教訓:洩漏後最重要的是馬上輪替,不是事後補措施。把「撤銷 → 產新 → 部署 → 確認」的流程寫進 OPERATIONS.md,下次遇到不要思考直接跑。
9. 安全 / 維運加固
9.1 完整 public/_headers
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: <第 8 章踩坑 2 的整併版本>
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/admin/*
Cache-Control: private, no-store
X-Robots-Tag: noindex, nofollow
/vault/*
Cache-Control: private, no-store
X-Robots-Tag: noindex, nofollow
/api/cloud/*
Cache-Control: private, no-store
X-Robots-Tag: noindex, nofollow
9.2 監控 / 告警
UptimeRobot(免費 50 monitors):
- 註冊 https://uptimerobot.com
- Add New Monitor → Type: HTTP(s) → URL:
https://personal-site-XXX.pages.dev/api/health→ Interval: 5 min - Alert Contact 加 email / Telegram bot
functions/api/health.ts:
export const onRequestGet: PagesFunction = async ({ env }) => {
return Response.json({
ok: true,
ts: new Date().toISOString(),
checks: {
b2: !!(env as any).B2_ENDPOINT,
jwt: !!(env as any).VAULT_JWT_SECRET,
},
});
};
9.3 帳單防爆
Cloudflare:Pages 流量無上限,不會被收錢。Workers free tier 每天 100k 請求,個人站絕不會撞到。
Backblaze B2:到 Account → Caps & Alerts:
- Daily Bandwidth Cap:0.99 GB(剛好低於 1 GB 免費上限)
- Daily Storage Cap:10 GB
- Daily Class B/C Transactions:2,400 / 2,400
這是物理上限,到了會自動暫停服務(不是寄信提醒),絕對不會被收費。
10. 結語:12 步驟 checklist
如果你想做一樣的網站,這是最小步驟:
npm create astro@latest建專案,選 minimal template- 加 Tailwind v4,定
src/site.config.ts寫站台名稱/你的名字/關於文字 - 寫
src/content.config.ts加public: boolean欄位 - 寫
src/lib/content.ts的getPublic/getPrivatehelper - 寫公開頁(首頁、portfolio、blog、resume、about、contact)
- push 到 GitHub,接 Cloudflare Pages
- 開 Backblaze B2 bucket + application key,設 Cloudflare Pages 環境變數
- 寫
functions/api/cloud/[[path]].ts用 aws4fetch 打 B2 - 建 GitHub OAuth App,寫
functions/lib/auth.ts+functions/_middleware.ts+functions/api/auth/{login,callback}.ts - 設
VAULT_*4 條環境變數 - 部署
workers/sveltia-cms-auth/Worker、放public/admin/{index.html,config.yml}接 Sveltia CMS - 設 UptimeRobot + B2 Caps & Alerts
按部就班大概 1-2 天可以做完。如果跟我一樣會撞到第 8 章那 5 個坑,可能要 3-4 天。
我會做 / 不會做的不同決策(給未來重做的我)
✅ 仍會選的:Astro + Cloudflare Pages + Backblaze B2 + 自架 GitHub OAuth + Sveltia CMS。每個都不貴、不綁卡、可換性高。
⚠️ 會更早做的:
- 第一天就統一單條全域 CSP,不要試 per-path override
- 第一天就不要寫
_redirects,信任 Cloudflare 預設 - 第一天就鎖死 dependabot 主版本升級 ignore rule
- 任何 secret 只在 dashboard / wrangler 互動式輸入,零例外
❌ 不會再嘗試的:Cloudflare Access Email OTP 當主要登入機制(dashboard 改版風險太大)。
延伸閱讀
- Astro 官方文件:https://docs.astro.build
- Cloudflare Pages Functions:https://developers.cloudflare.com/pages/functions/
- Backblaze B2 S3 API 對照表:https://www.backblaze.com/docs/cloud-storage-s3-compatible-api
- aws4fetch source(很短,建議讀過):https://github.com/mhart/aws4fetch
- Sveltia CMS docs:https://github.com/sveltia/sveltia-cms
完整 source code(含本文每段程式碼的可執行版本):github.com/yian524/personal-site。