深挖一个没有服务器的博客:Astro + Cloudflare Pages + D1 + Git-as-CMS 的每一层

ydredx52 分钟阅读0 次阅读

上一篇《深挖一个 Laravel 个人博客》拆的是一台 VPS 上的 OpenResty + PHP-FPM + MariaDB + Redis。这一篇是它的续集,也是它的反面:同一个博客,我把它重写成了一套没有服务器的东西——静态文件跑在 Cloudflare 的边缘,动态数据落在 D1(边缘 SQLite),而「发一篇文章」变成了「往 Git 仓库里提交一个 commit」。

没有自管服务器、Nginx/PHP 进程和 MySQL 实例要维护,固定 VPS 账单换成了 Cloudflare/GitHub 的计划与用量账。代码仓库能证明架构和用量防线,不能证明当前账号套餐或账单,所以本文不再把「¥0」写成无条件结论。下面按当前代码把每一层拆开讲清楚——它怎么工作、为什么这么设计、以及哪里有坑。

在线:https://www.ydxred.com (这篇文章本身就是用下面讲的「发布即 Git 提交」发上来的)

从「一台服务器」到「零服务器」

老博客是典型的三层:Nginx 收请求、PHP 渲染 HTML、MySQL 存数据。任何一个页面都要 PHP 现算,任何一次发文都写进数据库。这套东西能打,但要一台常年在线的机器,要打补丁、要防注入、要盯着 CPU 和磁盘。

新博客把这三层全拆了,换成三个互不驻留服务器的东西:

  • 内容 = Git 仓库里的 Markdown 文件。没有 CMS 数据库,文章就是 content/articles/*.md,版本化、可回滚、可 diff。
  • 页面 = 构建期算死的静态 HTML。Astro 在 CI 里把 Markdown 编译成纯静态站;当前部署会先经过一层极薄的根 Functions 路径归一化门禁,再由 ctx.next() 交给静态资源/CDN,不做 SSR。
  • 动态 = 边缘函数 + 边缘数据库。浏览量、点赞、评论这些「必须活」的东西,下沉到 Cloudflare Pages Functions(边缘 Worker)+ D1。

先看一张全景图,后面每一节都是在放大它的某一块:

CF-native 博客整体架构:构建期、边缘运行时、发布回路三个平面

图里有三个平面,正好对应文章的主干:① 构建期把 Markdown 编译成静态站;② 边缘运行时用 Pages Functions + D1 提供动态能力;③ 发布回路把「发文」翻译成一次 GitHub commit,再由 Actions 自动构建部署、闭合成环。

能力集:一个人用得舒服的完整博客

架构变了,但对外的体验和老博客对齐,一样不少:

  • 文章:Markdown 编辑、拼音 slug、标签、封面、目录 TOC、代码块高亮 + 一键复制、相关文章、阅读时长。
  • 动态(说说):类微博短内容,多图,内联发布器。
  • 互动:浏览量、点赞(带密钥的访客假名去重)、评论(先审后现)。原始 IP 不写入 D1。
  • 发布:后台 /admin 所见即所得编辑,点发布 → 内容以 commit 进仓库 → 全量 CI 门禁通过后通常约 5~7 分钟自动上线。还有一个密钥鉴权的 API 端点给脚本发文。
  • 搜索 / RSS / Sitemap:Pagefind 全文搜索(纯客户端,无后端)、手写 RSS 2.0 与 sitemap。
  • 鉴权:日常后台认 12 小时自建会话或 Cloudflare Access,任一通过即可;改密码与生产 readiness 只认 Access。所有受保护路径仍在 Functions 内独立验凭据。
  • 前端工程:中文字体自托管(当前构建 505 个 woff2 子集,按 unicode-range 懒加载),零 Google Fonts。

技术栈小到一张便签能写完:astro + pinyin-pro + marked + pagefind + @fontsource/noto-* + wrangler,外加 Cloudflare 的 Pages / Functions / D1 / Access。下面开挖。

一、静态生成层:Markdown 如何变成类型安全的静态站

一切的起点是 content/ 目录下的 Markdown。Astro 的 Content Collections 负责把「一堆 md 文件」变成「一套类型安全的数据」。定义只有一个文件 src/content.config.ts

// 空数组在导出的 YAML 里是 null,这里统一兜成 []
const list = () =>
  z.union([z.array(z.string()), z.null()]).optional().transform((v) => v ?? []);
const safeId = () => z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
const safeCount = () => z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);

const articles = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './content/articles' }),
  schema: z.object({
    id: safeId(),
    title: z.string(),
    slug: z.string(),
    excerpt: z.string().optional().default(''),
    cover_image: z.string().optional().default(''),
    tags: list(),
    author: z.string().optional().default(''),
    published_at: z.string().optional().default(''),
    updated_at: z.string().optional().default(''),
    views: safeCount().default(0),
    likes: safeCount().default(0),
    status: z.string().optional().default(''),
    is_visible: z.boolean().default(true),
  }),
});

glob loader 把目录里所有 .md 扫成集合成员,每个文件的 YAML frontmatter 交给 zod schema 校验。这份 schema 不只是类型断言,它还干两件脏活:默认值兜底excerpt/author 默认空串、views/likes 默认 0、is_visible 默认 true),和那个不起眼但很关键的 list() helper——因为导出的 YAML 里空数组会被序列化成 nulllist()z.union([array, null]).transform(v => v ?? []) 把它强制兜回 [],否则下游一个 tags.map() 就能把整个构建炸掉。校验通过后,getCollection('articles') 返回的就是完全类型化的对象。

分页:构建期就把 //2/3 铺好

首页是一个 rest 参数路由 [...page].astro,用 Astro 内建的 paginate()构建期生成所有分页页:

export const getStaticPaths = (async ({ paginate }) => {
  const articles = (await getCollection('articles'))
    .filter((a) => a.data.is_visible && a.data.status === 'published')
    .sort((a, b) => (b.data.published_at || '').localeCompare(a.data.published_at || ''));
  // …顺带算出 filterTags(去重收集用到的标签,查 tags.json 补颜色)…
  return paginate(articles, { pageSize: 10, props: { filterTags } });
}) satisfies GetStaticPaths;

第 1 页落在 /,之后是 /2/3。标签页更进一步,用 flatMap每个标签都铺一整套分页路由:

return filterTags.flatMap((tag) => {
  const tagged = articles.filter((a) => (a.data.tags || []).includes(tag.name));
  return paginate(tagged, {
    pageSize: 10,
    params: { slug: tag.slug },
    props: { filterTags, currentTag: tag },
  });
});

于是 /tag/tech/tag/tech/2/tag/life… 每个标签一套独立分页,HTML 都在构建期算好,运行时不再做内容渲染或查询。首页和标签页共用同一个 ArticleFeed 组件,靠 basePath / currentTag 两个 prop 区分链接基址和高亮态;请求本身仍会经过全站根 middleware。

一个反直觉但重要的点:过滤即安全边界

注意上面每处过滤都是 is_visible && status === 'published'。在老 Laravel 里,隐藏文章是「运行时判断,游客访问返回 404」。这里的公开文章路由没有按文章逐请求授权——页面一旦生成,就会作为公开静态产物分发。所以过滤不是 UI 筛选,而是安全边界

// 只构建「已发布 + 前台展示」的文章。公开文章路由不做逐篇授权,
// 若把 is_visible=false 的页也生成,等于对外泄露(与 Laravel 前台 404 行为不符)。
return articles
  .filter((entry) => entry.data.status === 'published' && entry.data.is_visible)
  .map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));

is_visible: false 的文章,详情页在构建期根本不生成——不是运行时藏起来,而是这个 URL 压根不存在。这个语义差异是从「服务器渲染」搬到「静态生成」时最容易踩的坑:任何「本该藏起来的东西」,只要进入公开产物就等于公开。列表、详情、标签、RSS、sitemap、搜索索引和 API 目标 allowlist 必须共享同一可见性口径。

顺带一提:两个集合,同一套机制

content.config.ts 定义了两个集合——articlesmoments(动态),共用 glob loader、正整数/计数约束和 list() 兜底,只是 schema 字段不同:动态没有 title/slug,多了 images 数组。新动态的 id 不再直接取 Unix 秒,而是把内容、标签、图片提示、毫秒与 isolate 内序号派生成安全落入 JavaScript/D1 的 53-bit 正整数;旧的迁移数据仍保留 13 这类小 id。文件是 content/moments/{id}.md,固定链接是 /m/{id}。内容真源仍是 content/ 下这两个 Markdown 目录,git log 能追踪完整历史。

二、正文渲染与代码块:为什么主动关掉语法高亮

文章正文由 Astro 内置的 markdown 管道渲染。详情页 article/[slug].astro

import { getCollection, render } from 'astro:content';
const { Content } = await render(entry);   // 构建期编译,零运行时开销
// …
// <div class="prose ..."><Content /></div>

这里有个容易误判的点:package.json 里装了 marked,但正文不是 marked 渲染的marked 只在后台编辑器里做客户端实时预览,对外的文章页走的是 Astro 的 render()

astro.config.mjs 现在同时承担构建来源门禁和 Markdown 安全边界,和初版那份 7 行配置已经不同。关键部分是:

import './scripts/block-native-pages-build.mjs';
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import rehypeSanitize from 'rehype-sanitize';
import { markdownSanitizeSchema } from './src/lib/markdown-sanitize.mjs';

export default defineConfig({
  site: 'https://www.ydxred.com',
  markdown: {
    syntaxHighlight: false,
    processor: unified({
      rehypePlugins: [[rehypeSanitize, markdownSanitizeSchema]],
    }),
  },
});

仍然主动关掉 Astro 自带的 Shiki:避免它在构建期生成的 token span 和客户端 highlight.js 打架。与此同时,rehype-sanitize 覆盖所有 Markdown 来路;按当前 Astro 管线顺序,正文里的原生 HTML raw 节点会整块丢弃,而 fenced code 的语言 class 会按自定义 schema 保留。这使拿到 PUBLISH_KEY 的脚本不能靠文章 HTML 在管理员预览同源执行脚本。

代码块的「卡片化」全在浏览器里完成——加载 highlight.js 后遍历每个 <pre>,动态包一层带 mac 三色点、语言标签、复制按钮的 .cb 卡片:

import hljs from 'highlight.js/lib/common';
import nginx from 'highlight.js/lib/languages/nginx';
hljs.registerLanguage('nginx', nginx);

content.querySelectorAll('pre').forEach((pre) => {
  const code = pre.querySelector('code');
  if (!code) return;
  // 语言:高亮前先取,避免被 hljs 改写 class
  const m = (code.className || '').match(/language-([\w-]+)/);
  const lang = m ? m[1] : 'text';
  hljs.highlightElement(code);
  // 造 .cb 容器 + .cb-head 顶栏(三色点 + 语言 + 复制按钮),把 pre 塞进去 …
  head.querySelector('.cb-lang').textContent = lang;   // textContent 填充,防 XSS
});

顺序是有讲究的:语言名必须在 hljs.highlightElement 之前className 取出来,否则 hljs 改写 class 后正则就匹配不到了。语言标签用 textContent 而非 innerHTML 填充,避免代码块语言名成为 XSS 入口。

目录 TOC 同样是纯客户端现算的。构建期的标题没有 id(没配 rehype-slug),所以 TOC 在浏览器里扫 .prose 内的 h2/h3,用一个保留 CJK 的正则 slugify 生成锚点 id 回写,再用 IntersectionObserver 做滚动高亮:

const slugify = (t) =>
  (t || '').trim().toLowerCase().replace(/\s+/g, '-')
    .replace(/[^\w一-龥-]/g, '').slice(0, 60) || 'sec';
// …回写 h.id,h.style.scrollMarginTop = '5rem' 避开顶部吸附栏 …
const spy = new IntersectionObserver((ents) => {
  const vis = ents.filter((e) => e.isIntersecting);
  if (vis.length) setActive(vis[0].target.id);
}, { rootMargin: '-80px 0px -70% 0px' });

代价是深链锚点必须等 JS 跑完才生效——这是「把动态逻辑推到客户端」的通用副作用。

三、发布即 Git 提交:没有数据库的 CMS

这是整个博客最核心、也最反常识的一块。没有后台数据库存文章,那「发一篇文章」到底发生了什么?

答案是:浏览器先把图片逐张 POST 到跑在 Cloudflare 边缘的函数,换回短期签名凭证;最终再把 Markdown + 图片凭证 POST 给同一个函数。它用 GitHub 的 Git Data API 在仓库里凭空拼出一个 commit,把文章与全部图片路径原子挂到 main;GitHub Actions 监听到 push,跑完类型检查、单元测试、构建和 e2e,再执行 wrangler pages deploy,通常约 5~7 分钟后新文章上线。内容的唯一真源,就是 Git 仓库里那个 .md 文件。

发布即 Git 提交的端到端时序:边缘函数用 Git Data API 拼一个 commit,Actions 异步构建部署

commitFiles:手工拼一个 commit

心脏是 _github.ts 里的 getBranchSnapshot + commitFiles。GitHub 的底层 Git Data API 允许你不用 git 命令、纯靠 REST 造出一个 commit,但当前实现不是固定 6 次调用:发布开始先用两次 GET 钉住 branch commit/tree;最终阶段只为带 base64 的文件创建新 blob,已经暂存的图片直接复用验签后的 sha,再创建 tree、commit,并非强制更新 ref:

const snapshot = await getBranchSnapshot(env); // GET ref + GET commit,钉住 commit/tree

const tree = [];
for (const f of files) {
  if (f.del) {
    tree.push({ path: f.path, mode: '100644', type: 'blob', sha: null });
    continue;
  }
  if (f.sha !== undefined) {                 // 已 stage 且验签的图片
    tree.push({ path: f.path, mode: '100644', type: 'blob', sha: f.sha });
    continue;
  }
  const sha = await createBlob(env, f.base64); // 仅为现场内容再 POST blob
  tree.push({ path: f.path, mode: '100644', type: 'blob', sha });
}

const newTree = await gh(env, `/repos/${repo}/git/trees`, {
  method: 'POST',
  body: JSON.stringify({ base_tree: snapshot.treeSha, tree }),
});
const commit = await gh(env, `/repos/${repo}/git/commits`, {
  method: 'POST',
  body: JSON.stringify({ message, tree: newTree.sha, parents: [snapshot.commitSha] }),
});
await gh(env, `/repos/${repo}/git/refs/heads/${branch}`, {
  method: 'PATCH',
  body: JSON.stringify({ sha: commit.sha, force: false }),
});

关键有三层:base_tree 让新 tree 只描述增量;所有 contents/tree 读取都绑定同一份 snapshot;最后的 force:false 只允许 fast-forward。并发期间 main 已移动时,GitHub 的 409/422 会被收敛成「发布分支已更新,请重试」,不会用旧快照覆盖新内容。图片暂存阶段只创建尚未被 tree/ref 引用的 Git blob;最终 tree 同时引用文章 .md 与这些已验签 blob,再用一次 ref 更新原子公开。冲突或过期 token 可能留下未引用 blob,但不会留下「图片路径已公开、文章未提交」的半成品。

删除文件的冷门技巧:sha: null

删一篇文章不调什么删除 API,而是复用同一条 commitFiles 链路——往 tree 里塞一个 { path, mode:'100644', type:'blob', sha: null },GitHub 把它解释成「相对 base tree 把这个 path 删掉」。于是新增、覆盖、删除三种操作被统一成同一个抽象:「提交一棵增量 tree」。CommitFile 接口有五个字段:path 必填;text 把 Markdown/JSON 作为 GitHub 原生 utf-8 blob 发送;base64 只给二进制图片;sha 引用已由服务端暂存并验签的 blob;del 用于删除。后四种内容形态在运行时严格四选一,混用或缺失都会在 GitHub 请求前拒绝。文本不再先在 Worker 里逐字节转 base64,避免最大正文把 Free 计划的请求 CPU 烧光。删除是「软」的——文件从工作区没了,但 Git 历史还在,后台确认框明确写着「可从 git 恢复」。

buildArticle:把表单拼成 frontmatter

文章最终落地为带 YAML frontmatter 的 .mdbuildArticle 用模板字符串手拼,但当前模板不再把更新态写死:新建调用方传入 views/likes=0status=published 和可见性;更新调用方从旧文件保留 id、首发时间、计数和状态,可见性只有请求显式传布尔值时才改变,否则也沿用旧值。用户可控的顶层标量统一过 yamlStr()(压掉换行、转义反斜杠和双引号),防止输入破坏 YAML:

return `---
id: ${a.id}
title: "${yamlStr(a.title)}"
slug: "${yamlStr(a.slug)}"
excerpt: "${yamlStr(a.excerpt)}"
cover_image: "${yamlStr(a.cover)}"
${tgs}author: "${AUTHOR}"
published_at: "${yamlStr(a.publishedAt)}"
updated_at: "${yamlStr(a.updatedAt)}"
views: ${a.views}
likes: ${a.likes}
status: "${yamlStr(a.status)}"
is_visible: ${a.isVisible}
---

${a.body}`;

手拼产物的字段和第一节那份 zod schema 一一对应——发布端「写」的格式,就是生成端「读」的契约。

中文标题 → 拼音 slug,把冷字典成本放到模块启动

文件名和网址是 slug。含中文就用 pinyin-pro 转拼音:

import { pinyin } from 'pinyin-pro';

export async function slugify(s: string): Promise<string> {
  const raw = s || '';
  let base = raw;
  if (/[一-龥]/.test(raw)) {
    base = pinyin(raw, { toneType: 'none', type: 'string', nonZh: 'consecutive' });
  }
  return base.toLowerCase().replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-').slice(0, 80);
}

这里曾经用 await import('pinyin-pro') 做动态加载,但真实最大路径检查发现:冷字典导入会落进第一次中文发文的请求 CPU,而当前 Workers Free 是 10ms/request,单这一步就越界。当前改成静态 import,让字典在发布模块启动阶段载入;Cloudflare 对模块启动与单请求 CPU 分开设限,不能把启动预算误塞进业务请求。代价是发布模块启动更重,所以仍由本地启动回归和隔离 Workers canary 分别守住两条预算,不能只看 HTTP 200。

图片入库与路径穿越防御

图片先由前端重编码,再逐张以 base64 dataURL 调 stage-image。边缘函数验证格式、尺寸、像素预算和隐私元数据后,只创建一个尚未挂到任何 tree/ref 的 Git blob,并返回绑定用途、服务端路径、blob SHA、大小、像素数和 30 分钟有效期的 HMAC token。最终发布请求的 cover / images 只带 { stageToken };服务端验签后把 token 内的路径和 SHA 放进最终增量 tree,客户端不能指定仓库路径。

export function safeStoragePath(p: unknown): string | null {
  if (typeof p !== 'string') return null;
  if (p.length > 512 || p.includes('..')
      || p.split('/').some((part) => !part || part === '.')) return null;
  if (!/^(articles|posts)\/[A-Za-z0-9][A-Za-z0-9._/-]*\.(jpe?g|png|gif|webp)$/i.test(p)) return null;
  return p;
}

暂存路径完全由服务端按用途生成,并再次校验只落在 articles/posts/ 图片目录,写不到 functions/.github/。article create/update 每次最多处理 2 个新图片 claim:新 cover 与请求 images 的每一项合计计数;更新时仅沿用已有 coverPath 不算,重复 claim 仍按出现次数计入 CPU 额度。stage 的解码后硬上限是 JPG/WebP 40KiB、PNG 4KiB、GIF 16KiB;宽高各不超过 4096,单图不超过 1600 万像素,最终请求合计不超过 6400 万像素。历史脚本的最终请求仍兼容 legacy inline dataBase64,但内联项合计最多 1 张,且该张解码后 ≤8KiB;其余图片必须先 stage。新客户端都应走两阶段,逐张暂存请求里的 dataBase64 不占这个兼容名额。

这个 article 边界不是从本地代理耗时猜出来的。Cloudflare Free 真实隔离 canary stable6 的数据是:10k CJK 正文 × 2 个新图片 claim,各 500 样本中新建 P99/max=8/13ms、更新 P99/max=6/7ms,全部返回 200;20k 新建已到 P99/max=11/22ms。因此当前代码把正文 10,000 个 Unicode 码点和 2 个新图片 claim 作为上线硬边界。Moment 的 12 个图片 claim 在 unique/duplicate 各 500 样本中 P99 都是 8ms,max 分别为 9/15ms,因此上限从 15 收紧到 12。

同一轮 canary 也覆盖了站点设置:about_content 为 20,000 个 Unicode 码点时,500 样本 P99/max=5/16ms。因此「关于我」正文硬限 20,000 码点。

媒体清理走另一条边界:构建阶段扫描同一 commit 的内容引用,只把真实孤儿 primary 编译进清单;删除请求必须同时满足页面 SHA、清单 SHA 与 GitHub 当前 branch snapshot 一致,再从同一 tree 复核 primary/sibling,并用 non-force commit 做最终 CAS。因此运行时不再并发 GET 全部正文。实际规模 18 个内容 fixture、257 项 tree、20 条删除路径的 500 次隔离 canary 全部返回 200,CPU P99/max=5/10ms;15 个内容 fixture 的另一组 500 次为 P99/max=6/16ms,两组 P99 都满足 ≤8ms 的上线目标。

一核两端:三种凭据,一个核心

发布核心 publish() 被两个 HTTP 端点复用,鉴权边界如下:

  • publish.ts:给浏览器后台用,verifyAdmin() 接受自建会话或 CF Access JWT,任一通过即可。
  • publish-ext.ts:给脚本 / curl 用,只认独立高熵 PUBLISH_KEY

密钥比对特意用了常量时间比较,防时序侧信道:

function timingSafeEqual(a: string, b: string): boolean {
  const ea = new TextEncoder().encode(a);
  const eb = new TextEncoder().encode(b);
  if (ea.length !== eb.length) return false;
  let diff = 0;
  for (let i = 0; i < ea.length; i++) diff |= ea[i] ^ eb[i];   // 逐字节 XOR,不早退
  return diff === 0;
}

而且这个对外端点做了两层收敛:密钥缺失或强度不够就直接 403(fail-closed,宁可关也不裸奔);即便核心 publish() 支持动态 / 改 / 删,这个端点也只放行文章新建,以及用途严格限定为 article-cover / article-inlinestage-image。改删、动态和 moment 图片暂存都不能借这把密钥绕进来。

用 API 发文章:像调接口一样发博客

上面那个 publish-ext.ts 对外的样子,就是一个 HTTP 端点——不用开浏览器、不用登录,带上密钥 POST 一段 JSON 就发出一篇文章,特别适合脚本、定时任务或从别的工具同步内容过来:

curl -X POST https://www.ydxred.com/api/publish-ext \
  -H "X-Publish-Key: $PUBLISH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "article",
    "title": "用 API 发的一篇文章",
    "body": "# 正文\n支持完整 Markdown,代码块、表格都行。",
    "excerpt": "可选摘要,不填则为空",
    "tags": ["技术", "随笔"]
  }'

成功返回落地信息,slug 是自动从标题转的拼音、commit 是这次提交的 sha:

{ "ok": true, "type": "article",
  "slug": "yong-api-fa-de-yi-pian-wen-zhang",
  "url": "/article/yong-api-fa-de-yi-pian-wen-zhang",
  "commit": "0123456789abcdef0123456789abcdef01234567" }

主要字段:

字段说明
type固定 "article"(这个端点只发文章)
title标题,必填,≤200 字,中文自动转拼音 slug
body正文 Markdown,必填,≤10,000 个 Unicode 码点
excerpt摘要,可选,≤500 字;不填存为空字符串
tags字符串数组,≤6 个,标签不存在也照收
slug可选,自定义网址;不填由标题生成
cover可选封面,先以 use:"article-cover" 暂存,最终只传 { stageToken };新封面占 1 个新图片 claim
images可选正文内嵌图,逐张以 use:"article-inline" 暂存,最终每项只传 { stageToken };与新封面合计最多 2 个新图片 claim,重复 claim 也计数

密钥放 X-Publish-Key 头或 Authorization: Bearer 头都行。关键是它和后台走的是同一个 publish() 核心——所以 API 发的文章一样 commitFiles 拼 commit、一样触发 Actions 全量验证与构建,通常约 5~7 分钟上线,跟你在后台点发布没有任何区别。

有意为之的边界:这个端点只能暂存文章图片并发新文章。改、删、发动态都不对密钥开放——那些高权限操作只能走后台的自建会话或 CF Access 鉴权。等于把「对外自动化」收敛到最小口子:只能往里加文章,删不了、改不了、也碰不到动态。密钥是 bearer 凭证、谁拿到谁能发,所以它进的是 Cloudflare 的加密 secret,绝不进仓库。

后台编辑器:正文运行时鉴权读取

文章管理页不再把全部正文序列化进静态 HTML 或 sessionStorage,只保留标题、slug、日期和可见性。点「编辑」会跳到 /admin/article?slug=...,编辑器再请求需要 verifyAdmin/api/article-source;函数从同一份 Git branch snapshot 读取 Markdown,解析 frontmatter,并连同 40 位 source_commit 返回。读取失败时提交按钮直接禁用,避免空白编辑器覆盖原文。

slug 在编辑态只读,提交更新时必须把 source_commit 带回。若载入后 main 已变化,发布核心返回 409 要求重新载入;成功后回执的新 commit 又成为下一次保存的版本令牌。这样既把隐藏草稿正文赶出静态后台页,也给编辑保存加了乐观并发保护。

前端编辑器:预览与图片压缩都在浏览器里

后台编辑器的交互在浏览器完成——左边写 Markdown,右边用 marked.parse() 实时渲染;结果必须再过 DOMPurify 才能写进 innerHTML,因为构建期的 rehype sanitizer 管不到这块即时预览。正文引用的 /storage/{path} 暂存图,会先用 split/join 临时换成本地 dataURL 才显示。

图片也在浏览器里先重编码:最长边起始不超过 1600px,PNG 输入转为保留透明度的 WebP,其余转 JPEG;循环降低质量/尺寸,目标不超过 40KiB,并剥离 canvas 产物中的多余 JPEG/WebP 元数据,超限就明确失败。浏览器保留 base64 只用于本地预览和 token 临近过期时重传;最终发布 JSON 只放 stageToken。边缘函数再做结构、像素和隐私校验,创建 unattached blob,最终提交只验签 token 并原子引用 blob。

为什么不用现成的 headless CMS?

有人会问:要「Git 存 Markdown + 后台编辑」,不是有 Decap(前 Netlify CMS)、Tina 这类现成方案吗?用它们当然行。自己写这套的理由有三:一是要把密码日常会话与 CF Access 恢复/提权通道接进同一边缘鉴权;二是要让最终文章和图片引用通过一次非 force ref 更新原子公开;三是发布核心还承担了路径、并发、图片结构/隐私、旧 frontmatter 保值等站点特有约束。当前 _publish-core.ts + _github.ts 已超过两千行,早已不是「不到 400 行」的小玩具;换来的价值是这些约束都能在仓库里测试和审计。

四、边缘鉴权:自建会话与 Access 双通道

版本说明:本文初版是「后台只走 Cloudflare Access」。当前实现已经改成:日常后台和发布认自建会话或 Access 任一条,改密码和私有 readiness 才只认 Access;外部自动发布则只认独立的 PUBLISH_KEY。下面写的是当前代码口径。

正常写作从 /login 输入账号密码,成功后得到 __Host-ydx_session:有效期 12 小时、HMAC 签名,每张票的 jti 还登记在 D1,因而退出可只撤销当前票、改密码可全局作废旧票。Cloudflare Access 是另一条独立的管理员通道,也是改密码的强因子。应用层统一入口很短:

export async function verifyAdmin(req: Request, env: AdminEnv) {
  try {
    const session = await verifySession(req, env);
    if (session) return session;
  } catch { /* D1 会话链失败,继续试独立的 Access */ }

  try { return await verifyAccess(req, env); }
  catch { return null; }
}

export async function verifyHumanAccessStrict(req: Request, env: AdminEnv) {
  const identity = await verifyAccessIdentity(req, env, env.ACCESS_AUD);
  return identity?.kind === 'human' ? identity : null;
}

export async function verifyReadOnlyAccessStrict(req: Request, env: AdminEnv) {
  return verifyReadOnlyAccessIdentity(req, env);
}

Access 票据仍是 RS256 JWT,_access.ts 用 Web Crypto 自己验。JWKS 加载不只是「拉下来缓存」:响应状态、keys 结构、RSA 类型和可用 key 数都会校验;成功缓存 1 小时,失败负缓存 60 秒,并发加载共享同一个 promise。证书轮换出现未知 kid 时会绕过成功缓存强刷一次,但带 60 秒冷却和单飞,避免伪造 JWT 把证书端点变成请求放大器。

验签前强制三段都是合法 base64url、alg === 'RS256' 且有 kid;签名覆盖编码后的原始 header.payload。验签后再严格检查声明:

const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== 'number'
    || !Number.isFinite(payload.exp)
    || payload.exp <= now) return null;
if (payload.nbf !== undefined
    && (typeof payload.nbf !== 'number'
        || !Number.isFinite(payload.nbf)
        || payload.nbf > now + 60)) return null;
if (payload.iss !== `https://${domain}`) return null;
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!auds.includes(aud)) return null;

也就是说 exp 缺失、类型错误或刚好到期都拒绝,nbf 只有合法数字才享有 60 秒时钟容差,issaud 把票据绑定到预期团队与应用。只读链会用 verifyReadOnlyAccessIdentity 对两个预期 AUD 做一次 RS256 验签,再按实际匹配的 AUD 绑定身份类型;同时命中两个 AUD,或 JWT 同时带 emailcommon_name,都按歧义身份拒绝。normal admin AUD 只有带非空 email 的 human JWT 能进入管理写路径;带 common_name 的 service JWT 必须使用独立 ACCESS_CANARY_AUD,只允许 exact /admin/access-canary/ 与两个只读 GET。Access 域名或 AUD 配错时底层主动抛错:verifyAdmin 会把 Access 这条路视为失败,但仍允许已经验证成功的自建会话;strict 调用方则失败关闭。

全站根 _middleware.ts 会先反复解码、合并斜杠并解析 ./..,再判断归一化路径是否属于 /admin/doc_wiki;属于就调用 verifyAdmin,不属于才 ctx.next()。所以应用代码不再凭 blog.ydxred.com 之类的 hostname 猜「上游肯定验过」,子目录 middleware 也保留作冗余。Cloudflare 面板里是否另有边缘 Access 策略属于外部配置,代码能证明的是:后台静态产物在应用 middleware 这一层始终要出示自建会话或 Access 凭证。

登录到发文,一次请求的完整旅程

日常路径是:密码登录换 12 小时会话;选图时逐张 stage-image,边缘创建 unattached Git blob 并返回短期 stageToken;最终发布再次通过 verifyAdmin,验 token 后做一次非 force ref 更新。管理员也可以直接带 Access cookie 走同一条发布链。外部 /api/publish-ext 不接受这两类 cookie,只接受高熵 PUBLISH_KEY,权限仍被收窄到暂存文章图片和新建文章。

/api/me 故意不要求上游 Access 先放行,否则游客的客户端请求可能先被重定向、拿不到 JSON。它在函数内调用 verifyAdmin,返回 { authenticated, who },前端只据此揭示知识库入口和动态发布框。这里不是「完全无状态」:自建会话的撤销状态明确放在 D1;省掉的是需要长期维护的常驻应用服务器。

五、动态数据层:静态站里的浏览量 / 点赞 / 评论

静态站天生没有服务器,但浏览量、点赞、评论这三件事必须「活」。方案是把它们下沉到边缘:几个 TypeScript 端点跑在 Pages Functions 上,数据落在 D1(Cloudflare 的边缘 SQLite,binding 名 DB)。

这里是「历史基线 + 边缘增量」:frontmatter 的 views/likes 保存迁移前的存量,D1 只记迁移后的增量。页面先显示基线,再批量请求 /api/stats,最终展示的是基线加 D1 增量,不能直接拿后者覆盖前者。

静态基线 + 运行时水合:构建期烘进 HTML 的数字被客户端拉取的 D1 实时值覆盖

views:去重台账与聚合计数同一事务

POST /api/views 先通过构建生成的公开目标白名单;再把 Cloudflare 提供的 IP 只在请求内做带密钥 HMAC,数据库保存形如 h1:... 的假名,不保存原始 IP。D1 batch 先尝试插入 (type,target_id,actor,day) 唯一的 view_hits,同时受单访客每日 300 个不同目标和全站每日 1000 条新台账约束;第二条语句只有在前一条 changes() === 1 时才给 views 聚合加一。两条在一个事务里,刷新、并发重复和预算抑制都不会误加。

拿不到 IP 时不能伪造一个大家共用的 no-ip 身份:views 明确只读返回当前值,不写去重台账,也不增加聚合。

likes:明细去重,触发器维护聚合

点赞同样先验公开目标并生成 HMAC 假名。INSERT OR IGNORE ... SELECT(type,target_id,actor) 去重、单访客每日 300 个不同目标上限合进一条写语句;数据库触发器另有全站每日 500 条新赞预算。like_counts 由插入/删除触发器维护,接口和批量 stats 直接读聚合,不再每次 COUNT(*) 扫明细。无 IP 时也是只读降级。

浏览器里的 localStorage 只负责按钮体验,不是安全边界;服务端唯一约束和预算才决定能否新增一赞。

comments:原子 60 秒限流 + 先审后现

评论按 Unicode 码点校验名字 1~40 字、内容 1~1000 字,User-Agent 先做 slice(0, 300) 截断,落库状态固定为 pending。有访客假名时,限流判断与插入合成一条 INSERT ... SELECT ... WHERE NOT EXISTS,SQLite 串行写保证同一假名 60 秒内至多成功一条;全站每日还有 100 条评论预算。读取只返回 approved,并命中 (type,target_id,status,created_at) 复合索引。

本地开发或异常链路拿不到 IP 时,评论仍可用,但 ip 明确写 NULL,因此没有逐访客 60 秒限制;likes、views 和访客信标则只读或跳过。这比把所有无 IP 请求折成同一个伪身份更符合真实语义。

防线不止参数化

当前动态层除了 commentslikesviews,还有 view_hitslike_counts、访客记录、会话与多级预算表。共同边界包括:

  • 所有 SQL 用 .bind() 参数化,JSON 写请求要求精确的 application/json、限制实际读取字节并拒绝跨站 cookie 写请求;
  • type/id 先做类型校验,再查构建期生成的公开目标 allowlist,隐藏或不存在的目标不会进入写路径;
  • 单访客预算和全站 UTC 日预算由原子 SQL/数据库触发器执行,旧代码或并发请求也不能只靠「先查再写」越过;
  • 数据库触发器拒绝回退代码重新写入原始网络标识,定时清理再缩短假名台账的保留窗口。

评论虽有 pending 人工审核这道闸,渲染仍做 XSS 兜底(纵深防御):文章页把评论模板放进 <template>,clone 后一律用 textContent 塞用户数据,DOM API 天然不解析 HTML;动态页因为拼 innerHTML,配了个 esc()& < > " ' 转义。两条路径都假设「D1 里的内容可能是恶意的」。

基线与增量,以及仍然存在的边界

静态 HTML 里的数字是历史基线,JS 水合后才是「基线 + D1 增量」;禁用 JS、接口失败或只看抓取源码时自然只能看到基线,这不是两套互相覆盖的真相。GET /api/stats 一次最多批量查 60 个公开目标,列表页和详情页都复用它。

点赞仍没有账号体系和取消接口,本机的 localStorage 只记按钮状态;换浏览器后按钮会重新亮,但重复写仍会被服务端唯一约束忽略。动态现在已有 /m/{id} 独立详情页;顶部标签条仍未接真正的动态过滤逻辑,这是当前还保留的展示层限制。

六、搜索、slug、RSS、标签:构建期算死,运行时增强

这一层的统一哲学是:能在构建期算的都算死成静态产物,运行时只做客户端增强

站内搜索用 Pagefind,集成只有一行 npm 脚本:astro build && pagefind --site distastro build 先产出静态站,pagefind 再爬 dist/ 生成 WASM 倒排索引写回 dist/pagefind。搜索页运行时纯客户端拉索引、本地检索,零后端:

async function boot() {
  await import(/* @vite-ignore */ '/pagefind/pagefind-ui.js');  // 运行时拉,避开 Vite 打包
  const ui = new window.PagefindUI({ element: '#search', showSubResults: true, pageSize: 8 });
  // 承接首页搜索框传入的 ?q=:写进 input 并派发 input 事件,假装用户输入触发检索
}

首页搜索框只是 action="/search" 的 GET 表单,把关键词以 ?q= 带到静态搜索页,由客户端接管——跨页搜索无需服务端查询参数处理。Pagefind 只索引带 data-pagefind-body 的公开正文;当前包括可见文章详情、/m/{id} 动态详情、关于页和隐私页。后台、预览页和隐藏内容不带这个标记,构建后还会跑 check-pagefind-privacy.mjs 直接检查生成的索引与 fragment,发现后台路径或受保护内容就让构建失败。

RSS 和 sitemap 是两个 Astro endpoint(.ts 导出 GET),手写字符串拼 XML,零依赖:

const items = articles.map((a) => {
  const url = `${base}/article/${a.data.slug}`;
  const pub = a.data.published_at ? new Date(a.data.published_at).toUTCString() : '';
  return `    <item>
      <title>${esc(a.data.title)}</title>
      <link>${url}</link>
      <guid isPermaLink="true">${url}</guid>
      <description>${esc(a.data.excerpt || '')}</description>
      <pubDate>${pub}</pubDate>
    </item>`;
}).join('\n');

**动态(moments)**用 paginate 每页 20,内联发布器在 SSG 里恒渲染但默认隐藏,客户端两道闸才揭示:先用正则确保只有第 1 页显示,再 fetch('/api/me') 确认是登录的管理员,否则直接 return——游客与翻页后永远看不到发布框。

标签 slug 也走 pinyin-pro:中文标签名(如「量化」)在 tags.json 里映射成拼音 slug(liang-hua)作为路由。后台编辑器还有一份逐字符等价slugify(区别只是它用静态 import),做标题输入时的实时预填,配一个 slugTouched 标志——用户没手动改过 slug 就跟着标题重算,一旦手动编辑就停止覆盖。

七、字体与前端工程:零外链的中文站

中文字体是性能大头。这里彻底摆脱 Google Fonts,用 @fontsource 把字体打进构建:

@import '@fontsource/noto-sans-sc/400.css';   /* 正文 */
@import '@fontsource/noto-sans-sc/500.css';
@import '@fontsource/noto-sans-sc/700.css';
@import '@fontsource/noto-serif-sc/600.css';   /* 标题 */
@import '@fontsource/noto-serif-sc/700.css';

@fontsource 把每个权重按 unicode-range 预切成上百个子集(CJK 字库巨大,按码点区间分片)。当前 prebuildbuild-fonts.mjs 生成非阻塞的 /fonts/fonts.css,构建日志确认是 505 个 woff2、505 条 @font-face。浏览器只在页面实际用到某个码点区间时才下载对应子集,配 font-display: swap 先用系统字体兜底再换;不会为一页正文下载整套 CJK 字库。

八、部署与边缘配置

生产部署主流程在 deploy.ymlpushmain 触发;另有 prune-d1.yml 每日做保留期清理,并与部署共享同一个 D1 写锁。部署关键结构如下(重复的 freshness 条件、secrets 和生产校验细节以仓库中的实际 workflow 为准):

on:
  push:
    branches: [main]
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with: { node-version: 24 }
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
      - run: npm run test:e2e
  deploy:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 360
    concurrency:
      group: ydxred-production-mutations
      cancel-in-progress: false
      queue: max
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - name: Check remote main freshness
        id: freshness
        run: bash scripts/check-main-freshness.sh
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        if: steps.freshness.outputs.current == 'true'
        with: { node-version: 24 }
      # 此处先查 Cloudflare 项目配置,并以无 phase canary 验证旧部署恢复链
      - run: npm ci && npm run build
      - name: Add deployment marker
        run: |
          mkdir -p dist/__deploy
          printf '%s\n' "$GITHUB_SHA" > "dist/__deploy/$GITHUB_SHA.txt"
      - name: Recheck remote main before D1 mutation
        id: mutation_freshness
        run: bash scripts/check-main-freshness.sh
      - name: D1 pre-deploy migration
        run: npx wrangler d1 execute ydxred --remote --yes --file=./migrations/0001_admin_sessions_predeploy.sql
      - name: Recheck remote main before Pages deploy
        id: deploy_freshness
        run: bash scripts/check-main-freshness.sh
      - name: Deploy to Pages
        run: >-
          npx wrangler pages deploy dist --project-name=ydxred --branch=main
          --commit-hash="$GITHUB_SHA" --commit-dirty=false
      # 真实 workflow 先从 www 读回精确 marker,再运行私有 readiness
      - name: Verify deployed Access recovery and readiness (pre)
        run: bash scripts/check-access-recovery-canary.sh pre
      - name: D1 post-deploy privacy migration
        run: npx wrangler d1 execute ydxred --remote --yes --file=./migrations/0002_anonymize_ip_postdeploy.sql
      - name: Verify private readiness post
        run: bash scripts/check-access-recovery-canary.sh post

有两个细节值得说。

每个 main SHA 都跑完整门禁,不能让内容提交按单次 diff 跳过;但也不能在 workflow 顶层取消旧运行,因为它可能已经部署新函数、却还没完成不可逆匿名化和 post-readiness。 现在把 deploy 与每日 prune 都放进 ydxred-production-mutations 并发组,设 cancel-in-progress: falsequeue: max。旧 SHA 拿到锁后先比较远端 main,构建完成且首个 D1 写入前、Pages deploy 紧前还会复查。过期 SHA 不会切换 生产别名;一旦精确 marker 证明它已切换生产,就继续完成不可逆迁移和 post-readiness,不在中间取消。

部署前后还夹着两段 D1 迁移和生产 marker/readiness 校验:先做兼容旧代码的加法迁移, 精确确认新 SHA 已接管生产别名后,才执行不可逆的旧网络标识匿名化。第一段迁移前还会 用现有 Cloudflare token 调官方项目 API:Direct Upload 可直接通过;Git-integrated 项目若没有同时关闭 production 和 preview 原生部署,整条流水线 fail closed。随后用 Access service token 做分阶段迁移:部署前无 phase 模式先走独立 /admin/access-canary/ 的新 AUD;首发旧部署没有该页面/协议时才清空 cookie jar, 回退 /admin/password 的旧 ACCESS_AUD 并只读 GET /api/password。精确 marker 后的 pre/post 则必须从独立应用的 exact /admin/access-canary/ 取得 ACCESS_CANARY_AUD cookie,再只读 GET /api/password 和私有 readiness。新链 验证完成后删除 broad admin 应用里临时保留的旧 Service Auth policy。service 身份 拿不到真实管理员用户名,也不能改密、发文或进入普通后台。readiness 本身 strict 验 Access JWT,匿名访问不会触发 GitHub PAT canary 或 D1 查询。

配置分层也很清楚。wrangler.toml[vars] 只放仓库、分支、Access team domain、human ACCESS_AUD 和独立 ACCESS_CANARY_AUD 这些非机密;GITHUB_TOKENPUBLISH_KEYSESSION_SECRETIMAGE_STAGE_SECRET 是生产必需的 Pages secret_text。其中 IMAGE_STAGE_SECRET 只用于签 staged-image capability,必须是独立的 43 字符无填充 base64url 随机值,不能拿会话或发布密钥复用。IP_HASH_SECRET 可选,但一旦设置也必须是加密 secret;没设时,访客假名会用 SESSION_SECRET 做独立域分离的 HMAC。[[d1_databases]] 声明 binding = "DB",函数里就能从 env.DB 取得句柄。部署脚本只校验 secret 的名称与类型,不读取或打印值。

GitHub Actions 的部署凭据是另一层 repository secrets:CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKEN 负责查项目和部署,后者只需 Cloudflare Pages: EditD1: EditCF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET 是独立 canary 应用的 Access service token。它们不是 Pages Functions 的运行时 env,也不能和上面四个应用密钥混为一谈。

Pages Functions 的组织靠两条命名约定:functions/api/comments.ts/api/comments(文件即路由);下划线前缀的文件(_utils/_access/_github/_publish-core)不是路由、只是被 import 的共享库;_middleware.ts 是目录级前置拦截器。此外还有 public/_redirects 收口历史路径。当前仓库存在根 functions/_middleware.ts,因此凡是继续进入 Pages 应用的请求——包括静态文章和资源——都会先经过它,不能再把静态请求描述成「绝不会调用 Function」。

请求的一生:一次访问在边缘经历了什么

把整套东西合起来,看三种请求分别怎么走,就懂了这个架构的运行时全貌。

读一篇文章(最常见):请求先经过根 middleware 的路径归一化;公开路径立即 ctx.next(),再由 Pages 返回构建期生成的静态 HTML,这一步不查 D1。HTML 到手后,客户端 JS 才异步高亮代码、生成 TOC、POST /api/views 尝试记录一次当日去重浏览,并批量取统计与评论。主体内容仍是静态首屏,但为保护编码变体下的后台路径,已经有一次轻量 Functions 调用,不能把它算成纯 CDN 路径。

发一条评论POST /api/comments 命中 Function 路由,校验目标与长度,把请求中的 Cloudflare IP 转成 HMAC 假名,再用一条原子语句完成 60 秒限流和 D1 插入,返回「待审核」。原始 IP 不进入 D1;这条路径不重建站点。

发一篇文章:就是前面第三节那条 POST /api/publish → Git commit → Actions 构建部署的链路,是三种请求里唯一会触发「站点重建」的。

三种请求的代价递增、频率递减:读文章最频繁,只有根 middleware 的轻量字符串处理和静态文件分发;互动会再碰 D1;发布最重,会触发 GitHub API 和 CI 构建。把重操作压到低频路径上,是这套架构控制资源消耗的底层原因。

Pages Functions 的运行模型:isolate 与冷启动

Pages Functions 本质是 Cloudflare Workers——不是「一台常驻服务器」,而是在 V8 isolate 中按请求运行函数,实例可能随时回收。这解释了前面几处设计:JWKS 成功/失败缓存和单飞 promise 都是模块级变量,只在同一个 isolate 内复用;成功项最多 1 小时、失败项 60 秒,冷 isolate 的首次 Access 验签仍可能拉证书,未知 kid 还会按冷却规则强刷。pinyin-pro 则在发布模块启动时静态载入:启动稍重,但不再让第一次中文发文承担冷动态 import 的请求 CPU。进程内没有可依赖的持久状态;需要长期存在的会话撤销和业务数据都显式落在 D1。

本地怎么跑起来

本地开发不用连真的 Cloudflare。npm run builddist/ 后,npx wrangler pages dev dist 就能在本地把静态站 + Functions + 本地 D1 一起跑起来(默认 8788 端口);D1 用 --local 把数据写进 .wrangler/statewrangler d1 execute ydxred --local --file=./schema.sql 建表、--file=./seed.sql 灌种子数据。本地通常没有 CF-Connecting-IP:评论会以 ip = NULL 写入而不做逐访客 60 秒限流,views/likes/visit 则只读或跳过写入;登录因无法建立可靠限流身份而返回 503。代码也按「生产请求异常时仍可能缺头」设计,没有把这个头当作无条件保证。

九、这套架构的账:成本、取舍与边界

成本:固定 VPS 账单被换成 Cloudflare Pages/Functions/D1 与 GitHub Actions 的套餐、请求、存储和构建用量。代码里有去重与全站预算,能说明它在努力把个人博客用量压低;但仓库无法证明当前账号套餐、账单或未来定价,所以不能据此断言月成本恒为 ¥0。能确认的是:没有自管机器要打补丁,也没有自管 MySQL 实例要备份。

但天下没有免费的架构,诚实地说说取舍和边界

  • 发布有延迟。老博客写库即时可见;这里「发文 → 上线」隔着全量 CI 门禁,通常约 5~7 分钟。边缘函数是秒回的(后台立刻提示「已提交」),但站点验证与重建是异步的。对博客完全可接受,对「实时性要求高」的场景不合适。
  • 点赞按网络假名去重,粒度仍粗。同一 NAT 出口会映射成同一个 HMAC 假名,换网络又能再点;假名化降低泄库风险,但不等于用户账号或匿名数据。
  • 浏览量是去重热度,不是审计指标。同一假名同一目标同一天只计一次,并受访客/全站预算保护;代理切换和无 JS 访问仍会让它与真实人数不同。
  • Git 公开切换是原子的,但暂存不是事务资源。所有内容和图片通过一次 force:false ref 更新同时可见;并发 ref 冲突会返回可重试错误,不过失败前创建的 unattached blob 可能留成不可达对象,其保留/清理周期不由这套代码证明。
  • 平台依赖没有消失。内容发布依赖 GitHub API/Actions,鉴权与数据依赖 Cloudflare;可观测性和供应商故障仍要面对,只是不再维护 VPS 进程。

这套架构适合谁:内容更新不追求秒级实时、能接受「发布=提交」心智、希望少维护服务器、且内容天然适合版本化(博客、文档、周刊)的场景。反过来,强实时、复杂事务、大量结构化查询的应用,静态 + 边缘这套就不是好选择。

十、踩坑与反直觉设计(复盘)

根因 / 设计说明
隐藏文章要「不生成」而非只靠 UI 藏公开静态产物可被直接分发生成、搜索、RSS、sitemap 和目标白名单口径必须一致
主动关掉 Shiki 高亮构建期 token 与运行时 highlight.js 打架关掉才输出干净 <pre><code> 供客户端认领
删文件用 sha: nullGitHub Git Data API 的冷门约定新增/覆盖/删除统一成「提交一棵增量 tree」
pinyin-pro 放到发布模块启动冷动态 import 会直接烧第一次中文发文的 10ms 请求预算启动与请求分别设回归线,再用隔离 Workers trace 验收
deploy job 串行且不取消旧 SHA 会覆盖生产;强行取消又会切断迁移拿锁后校验远端 main,stale SHA 零写入退出
配置缺失时 verifyAccess 主动 throw防「配置没填被误判为放行」fail-closed,上层 catch 收敛成未授权
限流用字符串比 created_atISO ...Z 定宽格式字典序 == 时间序省掉日期函数,但依赖写入格式统一
slugify 有服务端 + 客户端两份两边都静态载入,但分别运行在 Worker 模块和浏览器编辑器改规则要同步改两处,否则预览与落库不一致

一句实话:把博客从「一台服务器」搬到「零服务器」,省的不是写代码的功夫,而是长期运维的心智负担——不用再半夜担心磁盘满了、PHP 有 CVE、数据库要不要备份。代价是你得接受一种新的心智:内容是 Git、发布是 commit、动态是边缘函数。想清楚这套边界,它是个人站点极其舒服的形态。

十一、同一层,两种答案:Laravel 版 vs CF 版

把两套架构逐层对起来看,最能看清「问题」和「答案」的分界——同样的需求,在两种形态里长成了完全不同的样子:

能力Laravel 版(一台 VPS)CF 版(零服务器)
页面渲染PHP 逐请求现算 + Nginx 微缓存 30s根 middleware 后分发构建期静态 HTML,无 SSR
内容存储MySQL 表 + Eloquent 模型Git 仓库里的 Markdown 文件
发文写库即时可见提交一个 commit → 全量 CI → 通常 5~7 分钟上线
评论/点赞多态commentable_type + commentable_idtype + target_id 两列
浏览量异步写 MySQLD1 当日去重台账 + 聚合计数 + 多级预算
鉴权Laravel 会话 + AdminMiddleware12 小时自建会话或 Access;提权只认 Access
图片优化服务端 ImageOptimizer 打水印/WebP前端 canvas 压缩后进仓库
中文 slugStr::slug(Pinyin::sentence())pinyin-pro 在发布模块启动时载入
站内搜索MySQL LIKE(通配符转义)Pagefind 客户端 WASM 索引
安全底线PHP 硬化 + Nginx 拒敏感文件根路径门禁 + 双通道鉴权 + 参数化 SQL/原子预算
运维打补丁、备份、盯 CPU/磁盘少管服务器,仍要管密钥、迁移、CI 与平台配置
成本口径固定 VPS 账单随 Cloudflare/GitHub 套餐与实际用量,仓库不能证明账单

有意思的是那些没变的东西:多态关联的本质(一套逻辑通吃文章和动态)、中文转拼音、防注入要参数化、隐藏内容不能泄露、字体要本地化——这些是内容型站点的本质复杂度,换什么架构都躲不掉。变的只是承载它们的形态:一张 MySQL 表变成两列 type/target_id、一次 Str::slug 变成一次发布模块里的 pinyin-pro 调用、一层 AdminMiddleware 变成一段边缘 JWT 校验。

而真正被消灭的,是另一类复杂度:Nginx 调优、FPM 进程数、OPcache、微缓存串号、disable_functions、磁盘和数据库备份——这些不是业务需要的,是「跑在一台自己的服务器上」这个部署形态强加的。把博客搬到边缘 + 静态,等于把这一整类复杂度连根拔掉。

结语

同一个博客,两种活法。Laravel 版是「一台机器扛起全部」的经典工程;这一版是「不再自管常驻服务器」的另一种极致。多态关系变成了 type + target_id 两列、Nginx 微缓存变成了带根门禁的静态边缘分发、ArticlePublisher 变成了 commitFilesdisable_functions 变成了应用层双通道鉴权——每一块的「问题」没变,但「答案」被换成了完全不同的形状。

自己把同一个东西用两套架构各撸一遍,最大的收获是看清了它们真正的分界:哪些复杂度是本质的(鉴权、防注入、内容建模),哪些只是某种部署形态强加的(运维、扩容、备份)。把后者削掉,剩下的就是一个干净、便宜、好玩的博客。

—— 完 ——


附:后续演进(2026-07-26 更新)

正文已经按 2026-07-29 当前代码校正;这一节保留的是 2026-07-26 当时的演进快照,用于解释旧方案为什么改变。涉及会话时长、CPU 套餐、D1 防线和 CSP 的旧口径,以正文及文末安全修订为准。

顺便先还一笔债。第九节里写着:

有一处外链依赖。代码块高亮的 highlight.js 目前从 cdnjs 加载——在一个否则完全自托管的站点里,这是唯一一个「墙 / CDN 抖动会影响」的点,是待收口的债。

收了。下面第二小节讲怎么收的。

1. 2026-07-26:从「全走 Access」到「密码日常 + Access 兜底」

第四节讲的是后台全由 Cloudflare Access 在边缘把关,邮箱验证码登录。方案本身没问题,问题是用起来烦——写篇文章要先去邮箱翻验证码,一天来回几次就受不了了。

新的分工是这样:

动作认什么要验证码吗
日常进后台、发文、看 wiki自建会话 CF Access当时为 30 天;当前为 12 小时
改账号密码只认 CF Access

支点全在第二行。日常登录图省事用密码,但「改密码」这个提权动作强制走强因子。于是密码即使泄漏,对方能发文改文,却改不掉密码、锁不了你——你从 Access 重设一次,token_version 递增,他手上那张会话票立刻作废。

双通道骨架保留至今;当前代码又把 D1 会话故障和 Access 配置故障分别收敛,避免一条链异常拖垮另一条:

// functions/api/_auth.ts
export async function verifyAdmin(req: Request, env: AdminEnv): Promise<string | null> {
  try {
    const session = await verifySession(req, env);
    if (session) return session;
  } catch { /* 继续尝试独立的 Access */ }
  try { return await verifyAccess(req, env); } catch { return null; }
}

export async function verifyHumanAccessStrict(req: Request, env: AdminEnv) {
  const identity = await verifyAccessIdentity(req, env);
  return identity?.kind === 'human' ? identity : null; // 改密 POST
}

export async function verifyReadOnlyAccessStrict(req: Request, env: AdminEnv) {
  return verifyReadOnlyAccessIdentity(req, env);       // 一次验签,按匹配 AUD 绑定身份
}

保留 Access 作第二条身份通道是有意的,但它不是配置灾难的魔法兜底:D1 管理员行丢失时,在 D1 与 SESSION_SECRET 都正常的前提下可用 Access 重设;若 SESSION_SECRET 或绑定本身缺失,必须先恢复配置,Access 只能证明身份,不能凭空完成密码哈希和会话签发。

顺带删掉了原来 admin/_middleware.ts 里的一行:

if (url.hostname === 'blog.ydxred.com') return ctx.next();   // 删了

它的意思是「这个子域已被 Access 在边缘拦过,到这必然已登录」。逻辑没错,但那是个靠域名推断身份的假设——哪天在 Zero Trust 面板里把应用作用域动一下,这行就从「合理的信任」变成后台裸奔。现在 verifyAdmin 在任何域上都自己验一遍,少一个假设。

踩坑一:不能把密码 KDF 和 Worker CPU 上限绑死

早期密码哈希按 10 万次迭代写在 Worker 里,随后在 workerd 里量了纯计算耗时:

迭代数当时本地 workerd,5 次中位当时用来评估的 10ms 假定
10,0002ms
25,0005ms
50,0007–8ms⚠️ 余量太薄
75,00011ms
100,00017ms

当时据此把 10 万轮降到 25,000,给 D1 和 HMAC 留余量。这解决了超时,却把密码 强度长期绑在 Workers Free 的 10ms/request 上。2026-07-29 的最终方案是把 PBKDF2-SHA256 固定 600,000 轮移到浏览器 WebCrypto:

  1. GET /api/login 只公开 {version,kdf,salt,iterations,proof_bytes},缺行或坏行也 返回同形 dummy 200;
  2. 浏览器派生 32-byte proof,POST /api/login 严格只发 {username,proof}
  3. Worker 只用 SESSION_SECRET 做域分离 HMAC、D1 原子限流和条件会话写入,认证请求 不运行 PBKDF2;
  4. 600k 新凭据只能从 human Access 保护的改密页写入。至少 16 位也只能在这个受信 UI 校验,因为 proof 不携带原始密码长度,后端不能假装还能验证长度。

历史 raw/peppered 25k 记录仍兼容。raw 行首次成功登录时只把同一个已验证 proof 包一层 SESSION_SECRET HMAC,保留原 salt/iterations/credential;登录请求不接受 upgrade 字段,旧 proof 持有者不能借登录改成攻击者选择的新凭据。后台会持续提示 站长经 human Access 重设为 600k。

保护边界因此变成:

  • 在线撞库——单个 HMAC 假名访客 10 次、全站 100 次 / 15 分钟,代理池轮换也 不能无限猜;
  • 只泄漏 D1——数据库保存的是 SESSION_SECRET 域分离 HMAC verifier,不能直接 验证候选 proof;
  • D1 与 SESSION_SECRET 同时泄漏——新记录仍有浏览器 600k PBKDF2;尚未由站长 重设的历史记录仍只有 25k,所以 UI 的升级警告不能忽略。

踩坑二:账号错和 proof 错必须同形

登录接口最自然的写法是这样:

if (!usernameMatches(username, row.username)) return fail('账号或密码不正确', 401);
const ok = verifyPasswordProof(proof, row.password_hash, env.SESSION_SECRET);

看起来没毛病,实际会让错误账号跳过 proof HMAC。改成两个条件都算完再一起判:

const userOk = usernameMatches(username, row.username);
const passOk = verifyPasswordProof(proof, row.password_hash, env.SESSION_SECRET);
if (!(userOk && passOk)) return fail('账号或密码不正确', 401);

当前还补上了公开参数枚举边界:真实行、缺失行、畸形行的 GET 都是 200 且字段同形; POST 的账号错与 proof 错统一措辞。这里守的是代码路径和响应形状,不宣称跨网络绝对 常量时间。

踩坑三:开放重定向,正则挡不住

登录后要跳回原来想去的页面(/login?next=/admin/),得防着有人塞个外站地址来钓鱼。我一开始写的是:

return /^\/(?!\/)/.test(raw) ? raw : '/admin';   // 只放行「单斜杠开头」

//evil.com 挡住了。但实测发现漏了这个:

输入浏览器解析到跨站上面的正则
/admin/本站放行 ✅
//evil.example.comhttp://evil.example.com挡住 ✅
/\evil.example.comhttp://evil.example.com放行 ⚠️

浏览器会把 URL 里的反斜杠规范化成正斜杠,/\evil.com 等价于 //evil.com。这类变体还有好几种,靠手写正则枚举迟早漏。改成交给将要执行跳转的那个解析器自己判

function safeNext() {
  const raw = new URLSearchParams(location.search).get('next') || '';
  if (!raw) return '/admin';
  try {
    const u = new URL(raw, location.origin);
    return u.origin === location.origin ? u.pathname + u.search + u.hash : '/admin';
  } catch { return '/admin'; }
}

不自己判 URL 合不合法,让 URL 构造器判——它和真正执行跳转的是同一套解析规则,不会有理解偏差。

2. 那笔「外链依赖」的债:highlight.js 自托管

第二节讲了为什么主动关掉 Shiki、把高亮交给客户端。但那份 highlight.min.js 一直是从 cdnjs 拉的:第三方域、没有 SRI、而且大陆读者常拿不到——对一个满篇代码块的中文技术博客,这意味着相当一部分人看到的是没高亮的灰字。

改成 npm 依赖、构建期打包:

import hljs from 'highlight.js/lib/common';
import nginx from 'highlight.js/lib/languages/nginx';
hljs.registerLanguage('nginx', nginx);

lib/common 就是 cdnjs 那份 highlight.min.js 的同一个 36 语言集,行为完全一致。额外注册了 nginx——它不在 common 里,但文章用到了,也就是说之前那两块 nginx 配置从来就没高亮过,改完才发现。

主题 CSS 也从 cdnjs 挪到本地。这里有个小插曲:在 .astro 的 frontmatter 里 import 'highlight.js/styles/atom-one-dark.css' 竟然没被打进产物,代码块变成没颜色的。改成在 article-code.css 里用 PostCSS 的 @import 才进去。构建产物里 grep hljs-keyword 确认过才算完。

3. 前端体积:两处数量级的浪费

中文字体表 505 条 @font-face

第七节说字体自托管、按 unicode-range 懒加载——这部分没错,字体文件确实是按需加载的。错的是我没算过声明本身有多大

Noto Sans/Serif SC 每个字重要 101 条 @font-face(按 unicode-range 切片),5 个字重就是 505 条、约 527KB 纯声明。它们全在 app.css 里,也就是说每个页面首屏都得先把这坨下完才开始渲染。

拆出去,用 preload + onload 非阻塞加载:

<link rel="preload" as="style" href="/fonts/fonts.css" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/fonts/fonts.css"></noscript>

麻烦在于 Astro 会把 frontmatter 里 import 的 CSS 一律并进那个阻塞样式表,用 ?url 也豁免不了——它照样额外注入一个 <link rel="stylesheet">。最后是写了个 scripts/build-fonts.mjs(挂 prebuild 钩子)把字体表拼进 public/fonts/,绕开打包器。

顺带发现 fontsource 同时提供 .woff.woff2,两套都进了 dist——.woff 那 490 个文件,2016 年之后的浏览器一个都用不到。

改前改后
阻塞 CSS(brotli 实测传输)120KB12KB
dist 总体积51M33M

动态页一次拉 9.57MB 图

图片管线本来就产出了 webp,public/storage/posts/.jpg.jpg.webp 并排躺着。但模板一直直接引原图:

<img src={`/storage/${image}`} loading="lazy" />

于是宫格缩略图按原图尺寸发:

文件线上实际仓库里已有的 webp
fYe5Q44V….jpg4,349,726 B227 KB
dva7TkaR….jpg3,622,156 B260 KB
vwjpUK1E….jpg1,994,487 B109 KB
合计9.57 MB0.62 MB

15 倍差距,而 webp 早就在仓库里了。加了个构建期查兄弟文件的小工具(src/lib/img.ts),套 <picture><source>,灯箱链接仍指向原图。

有个容易踩的细节:<picture> 默认是 display: inline,直接套上去会让里面 <img>h-full 塌成 0 高,宫格全乱。得显式给它 block w-full h-full

4. 2026-07-26:动态数据层的三个接缝

第五节讲的 D1 三张表设计本身没问题,问题出在它和静态层的接缝上。

编辑一次文章,隐藏文章就公开了

buildArticle 拼 frontmatter 时把四个字段写死了:

views: 0
likes: 0
status: "published"
is_visible: true

新建文章这么写没问题。但更新走的是同一个模板。也就是说:一篇 is_visible: false 的隐藏文章,只要在后台点一次保存,就会被静默改成公开,浏览量点赞同时清零。

修法是更新时把这些字段从旧文件读回来保值。顺带把原本用来判断存在性的 fileExists 换成 getFileText——同一次 GitHub contents 请求既确认文件在、又拿到旧 frontmatter,请求数不变。

当时,一行 curl 能持续制造 D1 写入

parseTarget 只校验「是正整数」,不校验目标是否真的存在:

const n = typeof id === 'number' ? id : Number(id);
if (!Number.isInteger(n) || n <= 0) return null;

当时的 /api/views 每次 POST 都写一行、没有限流。按当时拿来做威胁模型的「10 万写/天」免费额度口径,可以这样持续消耗写入:

while true; do curl -XPOST .../api/views -d '{"type":"article","id":'$RANDOM'}'; done

如果账号当时确实采用该额度,约 10 万次有效写就可能耗尽当天配额,连带评论和点赞失败。这说明「月成本必为 ¥0」从来不是代码能保证的结论;这里记录的是 07-26 的风险估算,不是当前账号账单或额度证明。

07-26 先加了 view_hits 当日去重和单访客 300 目标上限;当前实现又补完了当时没做的根治:构建生成公开目标 allowlist,不存在或隐藏的 id 直接 404;原始 IP 换成 HMAC 假名;台账插入与聚合计数放进同一 D1 batch;数据库触发器再把全站新浏览台账封顶到每日 1000。刷新灌水、随机 id 垃圾和代理池放大现在分别有独立防线。

07-26 修正:「双轨真相」这个说法不准确

第五节我写过一句:

双轨真相:构建期把 views/likes 快照烘进 frontmatter 作首屏基线,运行时客户端 fetch D1 实时值 textContent 覆盖。

这是初版文章当时的描述。07-26 排查发现它漏了两件事,而且会误导人以为可以直接切到 D1:

一是列表页压根没覆盖。首页/标签页只读 frontmatter,那是发布那一刻的快照,之后再没更新过。基线为 0 的新文章干脆不显示阅读数——哪怕 D1 里已经有十几次。

二是点赞数永远显示 0likes 表只有 POST 端点,没有 GET。页面渲染的是 frontmatter 里的 likes,而 API 发的文章那个值恒为 0。也就是说读者点的赞进了 D1,但没人看得见,只有自己点一下才会看到返回值。查生产库时发现里面确实躺着几个赞。

随后补了只读批量端点 GET /api/stats?type=&ids=1,2,3,一次请求填满整页,不写库所以不占匿名写预算;当前端点还会先过滤构建生成的公开目标 allowlist。

但真正值得记的是修的时候差点犯的错:本来打算把显示层直接切到 D1。2026-07-26 当时的生产数据快照表明不能这么干——

frontmatter 基线D1 实际
markdown-guide3655
22jC7p2H2483
cloudflare-cdn…011

frontmatter 里的数字不是垃圾,是从 Laravel 迁过来的历史存量;D1 记的是迁移之后的增量。直接切会让阅读数从 365 掉成 5,把历史抹掉。正确语义是两者相加,不是二选一。

而这也正好和上面「编辑会清零」那个修复接上了——基线保住了,加法才成立。

5. 2026-07-26:响应头合并这个反直觉行为

原来没有 _headers 文件,也就是没有 CSP、没有 HSTS、没有点击劫持防护。补的时候撞到一个坑。

当时构建里还有第三方生成的 public/doc_wiki/,模板自带 cdnjs 的 mermaid。为了给它单独放宽 CSP,我试过:

/*
  Content-Security-Policy: ... script-src 'self' 'unsafe-inline'; ...

/doc_wiki/*
  Content-Security-Policy: ... script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; ...

直觉上「更具体的路径覆盖通配」。实测不是——Pages 对同名响应头是合并,用逗号连起来

content-security-policy: <严格策略>, <宽松策略>

而按 CSP 规范,多条策略是取交集:资源必须同时被所有策略允许。所以那条「放行」规则等于没写,mermaid 照样被严格策略挡住。这个平台行为结论仍然有效。

当前结果已经不同:旧 wiki 整体移出构建,highlight.js 和 mermaid 都由 npm 构建为自托管资源,全站 CSP 的 script-src 收紧为 'self' 'unsafe-inline' 'wasm-unsafe-eval',不再为 cdnjs 开口;其中 wasm-unsafe-eval 只为 Pagefind 的 WebAssembly 搜索保留。也就是说,上面的两段 CSP 是历史踩坑样例,不是当前 _headers 内容。

这类东西写完不测就上线,报错现场会非常难懂——页面看着正常,只有某个图不出来,控制台一条 CSP 违规。

6. 复盘:这一轮踩的坑有什么共性

当时的想法实际
Worker 内 PBKDF2 100k只照通用建议,没做运行时预算本地测量显示余量不足;最终把浏览器 KDF 固定为 600k,Worker 只做 HMAC
正则挡开放重定向单斜杠开头就是站内浏览器把 \ 规范化成 /,漏了
更新文章复用新建模板都是拼 frontmatter,一套就够隐藏文章被翻公开、计数清零
parseTarget 只校验正整数类型对了就行随机 target_id 可持续写;当前已补生成式 allowlist
更具体的 _headers 覆盖通配大部分系统都这样Pages 是合并,CSP 多策略取交集
字体「按需加载」文件懒加载就够了505 条声明本身 527KB,每页首屏必付

共性很明显:几乎每一条都是「看起来能用,但有个前提我没验证」

而且这些前提有个共同特征——它们都在平台边界上:运行时的 CPU 配额、浏览器的 URL 规范化、托管平台的响应头合并规则、套餐额度的计费口径。业务逻辑写错了单元测试能兜住,这类东西只看代码不够,还要用对应平台的真实配置、响应或隔离运行证据验证。

所以这一轮真正的收获不是修了几个 bug,而是一条经验:凡是涉及平台配额、浏览器解析、第三方合并规则的判断,别只推理,去测,并标清测试环境。上面的性能和生产数据表都是 2026-07-26 的快照,不代表当前线上账号状态。

附:安全修订(2026-07-29 更新)

正文已按当前实现校正;07-26 复盘保留历史快照。当前代码在 07-29 又完成了一轮隐私和并发安全收口,以下内容概括这轮变化:

  • 自建会话从 30 天缩短为 12 小时,每张会话以 jti 登记在 D1;退出只撤销当前会话,改密码仍全局作废。
  • 浏览器执行固定 600k PBKDF2,Worker 只收 32-byte proof 并做 SESSION_SECRET 域分离 HMAC;旧 raw 25k 行只安全封装同一 proof,新的 600k 凭据 必须由 human Access 改密页写入。
  • Access JWT 保留 human/service 身份;GitHub Actions service token 使用独立 ACCESS_CANARY_AUD,只允许 exact canary 页面与两个只读 GET,不能改密或发文。
  • Cloudflare 提供的 IP 只在请求内用于带密钥 HMAC,D1 不再保存原始 IP;旧数据由部署后迁移匿名化,数据库触发器阻止回退代码重新写入原始网络标识。
  • 评论限流、浏览去重、点赞去重和访问额度都使用假名访客标识;额度判断与写入已合并为原子 SQL,避免并发请求一起越过上限。
  • 每日可观察的定时工作流替代请求路径中的概率清理:短期台账及评论假名标识/UA 以 7 天为目标窗口,访问记录和点赞关联以 90 天为目标窗口。

这里仍要诚实区分两个概念:假名化标识可被同站密钥稳定关联,因此不是匿名数据;GitHub 定时任务也可能延迟,因此 7/90 天是目标保留窗口,不是不可逾越的硬 SLA。

分享这篇文章

Related / 相关阅读

评论(0)

  • 加载评论中…

发表评论