閱讀約 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/* → 只有我登入後能用,存草稿、敏感檔案、後台寫文章

而且要符合三個硬條件:

  1. 月費 0 元:碩士生不想多一筆固定支出
  2. 不綁信用卡:怕忘了取消被扣款
  3. 每篇文章用一個 boolean 控制公私:寫稿時不想搬資料夾、改路徑

來看一下現成方案為什麼不夠:

方案缺點
Notion 公開頁私密頁要付費;每篇公私切換不夠細;SEO 慢
WordPress要自己防駭、外觀俗、plugin 一多就吃資源
GitHub Pages + 私 reporepo 整個私密可以,但單一頁面加密做不到
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。

最後決定的版本。第一版用 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-site repo,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/ 註冊(不用信用卡)。

步驟

  1. BucketsCreate a Bucket
    • Name:yian524-vault-files(必須全球唯一)
    • Files in Bucket are:Private
    • Default Encryption:Enable(用 B2 Managed Keys)
  2. 記下 bucket info 顯示的 Endpoint
    https://s3.us-east-005.backblazeb2.com
  3. App KeysAdd a New Application Key
    • Name:personal-site-pages-function
    • Allow access to Bucket(s):只勾剛才建的 bucket
    • Type of Access:Read and Write
    • 按 Create New Key
  4. 這頁顯示的 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 → SettingsEnvironment variablesProduction,新增 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。

聽起來完美。實作時撞到三件事:

  1. Cloudflare Zero Trust 改版——「Login methods」設定埋在三層子選單裡,官方文件跟實際 dashboard 路徑對不上
  2. OTP 驗證碼信被 Gmail 過濾——測試三次都收不到
  3. OTP 用過一次立刻 expire——重新觸發要等 cooldown

debug 兩小時無解後決定砍掉,全部自架。經驗:把核心安全綁在「dashboard 設定」上,dashboard 改版時你就死了

新架構流程圖:

使用者打 /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/developersOAuth AppsNew 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 applicationGenerate 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

預期流程

  1. 跳到 GitHub「Authorize Personal Site Vault」頁
  2. 按 Authorize
  3. 跳回 /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.mjstrailingSlash: '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.datactx.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):

  1. 註冊 https://uptimerobot.com
  2. Add New Monitor → Type: HTTP(s) → URL: https://personal-site-XXX.pages.dev/api/health → Interval: 5 min
  3. 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

如果你想做一樣的網站,這是最小步驟:

  1. npm create astro@latest 建專案,選 minimal template
  2. 加 Tailwind v4,定 src/site.config.ts 寫站台名稱/你的名字/關於文字
  3. src/content.config.tspublic: boolean 欄位
  4. src/lib/content.tsgetPublic / getPrivate helper
  5. 寫公開頁(首頁、portfolio、blog、resume、about、contact)
  6. push 到 GitHub,接 Cloudflare Pages
  7. 開 Backblaze B2 bucket + application key,設 Cloudflare Pages 環境變數
  8. functions/api/cloud/[[path]].ts 用 aws4fetch 打 B2
  9. 建 GitHub OAuth App,寫 functions/lib/auth.ts + functions/_middleware.ts + functions/api/auth/{login,callback}.ts
  10. VAULT_* 4 條環境變數
  11. 部署 workers/sveltia-cms-auth/ Worker、放 public/admin/{index.html,config.yml} 接 Sveltia CMS
  12. 設 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 改版風險太大)。

延伸閱讀

完整 source code(含本文每段程式碼的可執行版本):github.com/yian524/personal-site