这篇尽量写细一点,把 fanhexuan.com 从前台到后台、从构建到部署的实现都摊开讲清楚,包括最近一次由 Claude(Claude Code)协作完成的改造。既是给自己留的”架构备忘”,也方便以后接手维护或迁移。
一、整体架构与请求路径
本站是一个 Astro 静态博客:所有页面在构建期生成纯静态 HTML,运行期没有服务端渲染。一次访问的路径大致是:
浏览器
│
▼
Nginx (443, TLS)
├─ / → 直接返回 /var/www/fanhexuan.com/html 下的静态文件
└─ /admin/* → 反向代理到 127.0.0.1:4322 (本机的 Express 后台)
这么分有几个好处:前台是纯静态,访问快、好缓存、攻击面极小;后台只监听 127.0.0.1,不直接暴露公网,由 Nginx 在 /admin 前面挡一层。两个部分职责清晰:
| 部分 | 技术 | 目录 |
|---|---|---|
| 前台 | Astro(Content Collections + 动态路由) | astro-blog/,构建产物 dist/ |
| 后台 | Node / Express(systemd 服务) | fanhexuan-admin/server.mjs |
| 编辑器 | Milkdown Crepe(esbuild 打包的 bundle) | 单独构建后部署进后台的 public/ |
目录关系简化如下:
astro-blog/
├─ src/
│ ├─ content/blog/*.md # 文章(Markdown + frontmatter)
│ ├─ content.config.ts # Content Collection 的 zod schema
│ ├─ pages/
│ │ ├─ index.astro # 首页
│ │ ├─ blog/[...slug].astro # 文章详情路由
│ │ └─ series/[slug].astro # 系列页(动态生成)
│ ├─ layouts/BlogPost.astro # 文章页模板:目录 / 加密解锁 / 文件卡片
│ ├─ components/ # Header / Footer / BaseHead ...
│ └─ styles/global.css # 全站设计 token(配色 / 字体 / 阴影)
└─ scripts/protect-posts.mjs # 构建后加密带密码的文章
二、内容模型:Content Collections 与 frontmatter
文章是一堆 .md 文件,元数据写在 frontmatter 里,由 Astro 的 Content Collections 用 zod 校验类型。content.config.ts 的 schema 是这样的:
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(), // 字符串自动转 Date
updatedDate: z.coerce.date().optional(),
series: z.string().optional(), // 归入某系列
password: z.string().optional(), // 有则触发加密
heroImage: z.optional(image()),
}),
});
对应一篇文章的开头:
---
title: "文章标题"
description: "一句话摘要,用于列表与 SEO"
pubDate: "2026-06-12"
series: "站点手记"
password: "******" # 可选;写了就会被加密
---
好处是任何不合规的 frontmatter(比如日期格式错、缺 title)在构建期就报错,不会带着坏数据上线。
三、系列:一个动态路由搞定
系列页不需要手动维护。src/pages/series/[slug].astro 用 getStaticPaths() 扫描所有文章里出现过的 series,给每个去重后的系列生成一页:
export async function getStaticPaths() {
const posts = await getCollection('blog');
const map = new Map(); // series -> 该系列的文章
for (const post of posts) {
const s = post.data.series;
if (!s) continue;
if (!map.has(s)) map.set(s, []);
map.get(s).push(post);
}
return [...map].map(([series, posts]) => ({
params: { slug: series.toLowerCase().replaceAll(' ', '-') },
props: { series, posts },
}));
}
这里有个容易踩的一致性坑:slug 的推导规则 series.toLowerCase().replaceAll(' ', '-') 必须在所有地方保持一模一样——首页、blog/index、BlogPost.astro 里指向系列的链接都用同一套,否则链接和页面就对不上、404。
早期这些系列页是一个系列一个硬编码文件(cty.astro、dji-mini-se.astro…),结果新增系列(尤其是中文名)没有对应文件就 404。改成上面这个动态路由后,新系列——哪怕是中文的,比如本文所属的「站点手记」——会自动生成页面,中文 slug 构建成同名目录、URL 编码后也能正常解析。
四、加密文章:构建期加密 + 前端解密
有些研究笔记不想完全公开,于是做了一套”软加密”:纯静态站没有后端,所以加密发生在构建期,解密发生在浏览器。
构建期:protect-posts.mjs
构建命令是 astro build && node scripts/protect-posts.mjs。后面这个脚本会遍历文章,凡是 frontmatter 带 password 的,就:
- 读取已经构建好的
dist/blog/<slug>/index.html; - 取出正文里用注释标记包起来的那段 HTML:
<!-- protected-content-start -->...<!-- protected-content-end -->; - 用 AES-GCM-256 加密这段 HTML,密钥由 PBKDF2-SHA-256(210,000 次迭代) 从密码派生;
- 把密文连同算法参数写到
dist/protected/<slug>.json; - 最后把 HTML 里那段明文删掉——上线的页面里不含正文。
核心加密逻辑(Node 的 webcrypto):
const iterations = 210_000;
const encrypt = async (html, password) => {
const salt = webcrypto.getRandomValues(new Uint8Array(16));
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const material = await webcrypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
const key = await webcrypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
const ciphertext = new Uint8Array(await webcrypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(html)));
return { v: 1, alg: 'AES-GCM', kdf: 'PBKDF2-SHA-256',
iterations, salt: toBase64(salt), iv: toBase64(iv),
ciphertext: toBase64(ciphertext) };
};
产物 protected/<slug>.json 就是 { v, alg, kdf, iterations, salt, iv, ciphertext },全部 base64。
浏览器端:输入密码后解密
文章页(BlogPost.astro)里有一段脚本:访问者输密码 → fetch 对应的 /protected/<slug>.json → 用 Web Crypto 重新派生密钥并解密 → 把明文注入页面:
const deriveKey = async (password, salt, iterations) => {
const material = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
material, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
};
// 提交密码时:
const payload = await fetch(`/protected/${postId}.json`).then((r) => r.json());
const key = await deriveKey(password, decodeBase64(payload.salt), payload.iterations);
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: decodeBase64(payload.iv) }, key,
decodeBase64(payload.ciphertext));
container.innerHTML = new TextDecoder().decode(plain); // 解密后注入正文
安全边界要说清楚:密文是公开可下载的,理论上可以拿着它离线暴力破解密码。所以这套机制本质是”挡住顺手翻看的人”,强度取决于密码本身;PBKDF2 的 21 万次迭代提高了每次猜测的成本,但它不是用来对抗有决心、且密码很弱的攻击者的。对个人博客的”半私密”笔记来说够用,心里有数即可。
五、/admin 在线后台
后台是一个手写的 Express 服务(server.mjs),由 systemd 托管(fanhexuan-admin.service),只听 127.0.0.1:4322。
鉴权
- 密码用 PBKDF2-SHA-256 存成
pbkdf2-sha256$<迭代次数>$<盐>$<哈希>的格式,校验时用crypto.timingSafeEqual做定长比较,避免计时侧信道。 - 登录成功后在内存里建会话(
Map),下发一个httpOnly + Secure + SameSite=Lax、Path=/admin的 Cookie;会话有滑动过期(每次访问续期)。 - 所有非 GET 的接口都要校验
X-CSRF-Token,token 在登录时随会话一起发。
API 一览
GET /admin/api/status 会话状态 + CSRF token
POST /admin/api/login | logout 登录 / 注销
GET /admin/api/posts 文章列表(含 frontmatter 摘要)
GET /admin/api/posts/:slug 读单篇
POST /admin/api/posts 新建
PUT /admin/api/posts/:slug 更新(可改 slug = 重命名)
DELETE /admin/api/posts/:slug 删除
GET /admin/api/series 已有系列(含一组默认名)
POST /admin/api/uploads/image 传图片
POST /admin/api/uploads/file 传附件
POST /admin/api/publish 构建并发布
文章 CRUD 与一个”重命名陷阱”
PUT /admin/api/posts/:slug 在 body 里的 slug 和 URL 里的不一致时,会重命名文件——先写新文件,再 fs.unlink 旧文件:
await backupPostFile(oldFile, 'update'); // 先备份
await fs.writeFile(newFile, serializePost(fm, body)); // 写新
if (post.slug !== currentSlug) await fs.unlink(oldFile); // slug 变了才删旧
这个设计本身没问题,但前端如果在某个时刻 currentSlug 记错了,就可能把一篇文章覆盖/删掉。早期就因为一个启动竞态(“新建文章”点击后,后台自动加载最新文章把 currentSlug 又改了)真的误删过一篇。两道防线解决:
- 改前必备份:每次更新/删除前把旧文件复制到
.admin-backups/<时间戳>-<原因>-<slug>.md,误删能字节级恢复; hasUserNavigated守卫:用户一旦主动打开/新建文章就置位,启动期的自动加载不再覆盖用户的操作。
上传与”即时镜像”
图片传到 public/images/uploads/,附件传到 public/files/uploads/,按 YYYY-MM 分月份目录,文件名是 <时间戳>-<随机>-<安全化的原名><扩展名>。关键一步是镜像:上传后立刻把文件也复制一份到 Nginx 发布目录,这样还没点发布时图片/附件路径也不会失效:
const finalName = `${Date.now()}-${rand}-${safeName}`;
await fs.writeFile(path.join(targetDir, finalName), file.buffer);
await mirrorPublicFile(filePath); // 复制到 /var/www/.../html 同名路径 + chown www-data
mirrorPublicFile 里用 fs.realpath 做了越界校验(拒绝写到 public / 发布根目录之外),防止路径穿越。
发布
POST /admin/api/publish 就是 spawn('npm', ['run', 'build']),构建完把 dist/ 拷到发布目录;deployDist 同样用 realpath 校验,拒绝往非预期目录写。
编辑器
编辑器是 Milkdown Crepe(基于 ProseMirror 的所见即所得,语雀那种手感),从早期卡顿的 Vditor 迁移而来。它的源码单独用 esbuild 打成一个 IIFE bundle,对外暴露 window.CrepeEditor 的命令式 API(mount / load / getMarkdown / insertLink / ...),后台的 app.js(手写、无需打包)调用它。server.mjs 对编辑器是无感的——它只负责把 getMarkdown() 的结果当正文存盘。侧边栏是按系列分组的可折叠树,右侧是扫描标题自动生成的大纲。
六、构建与部署:两条流水线
前台/内容改动走这条:
# 在服务器上
cd astro-blog
npm run build # astro build && node scripts/protect-posts.mjs
rsync -a --delete dist/ /var/www/fanhexuan.com/html/
chown -R www-data:www-data /var/www/fanhexuan.com/html
编辑器改动走另一条(editor/deploy.sh):esbuild 打包 → scp 把 bundle / 样式 / 字体平铺进后台的 public/ → systemctl restart fanhexuan-admin.service → curl 冒烟检查。后台路由是 app.use('/admin/assets', static('./public/')),所以文件是平铺在 public/ 里、没有 assets/ 子目录——这点后面会再提到(它曾经坑了部署脚本一次)。
七、最近一次改造(AI 协作)
最近请 Claude(Claude Code)一起做了三件事,并顺手补了一些工程化的细节。贯穿始终的一个原则是:底层 Markdown 保持干净,花活全部放在”渲染期”做。
7.1 任意文件拖拽上传
之前编辑器只认图片,拖个 Python 脚本进去没反应。改动在 main.js 里把上传器从”只处理图片”扩展成”处理任意文件”:图片仍然作为图片节点插入,其他文件上传后插入成一个普通 Markdown 链接 [name](url):
async function fileUploader(files, schema) {
const out = [];
for (const file of files) {
if (file.type.includes('image')) {
const src = await handlers.onUploadImage(file); // → 图片节点
out.push(schema.nodes.image.createAndFill({ src, alt: file.name }));
} else {
const url = await handlers.onUploadFile(file); // → 链接节点
out.push(schema.text(file.name, [schema.marks.link.create({ href: url })]));
}
}
return out;
}
为什么是”普通链接”而不是自定义语法?因为这样 Markdown 能在编辑器和 Astro 之间无损往返——下次再打开文章,它还是个标准链接,不会因为编辑器不认识自定义语法而被吃掉。后台 app.js 把 onUploadFile 接到 /admin/api/uploads/file;服务端把允许的扩展名从 8 种放宽到约 60 种(脚本 / 代码 / 文本 / 压缩包 / 文档)。这是安全的:Nginx 把这些文件当静态文件返回,从不执行。
7.2 文件卡片 + 页内预览(前台)
光是一行链接太”呆”。前台用一段自包含的 <script is:inline> 把指向 /files/uploads/ 的独占一行的链接”升级”成文件卡片:图标、文件名、大小、下载按钮;文本/代码类可以在页面内展开预览,PDF 直接内嵌:
root.querySelectorAll('a[href*="/files/uploads/"]').forEach((link) => {
// 只升级"整行就是一个附件链接"的情况;句中链接不动
const block = link.closest('p, li');
if (block.textContent.trim() !== link.textContent.trim()) return;
// ...构建卡片:HEAD 拿 content-length 显示大小;按扩展名判类型
// 文本/代码:点击展开 → fetch 文本写进 <pre><code>(用 textContent,天然防 XSS,上限 200KB)
// PDF:内嵌 <iframe>
});
几个细节:大小用一次 HEAD 请求拿 content-length;预览内容用 code.textContent = text 写入(不解析 HTML,天然防 XSS),超过 200KB 截断并提示下载;加密文章解密后注入的内容也要增强,所以用一个 MutationObserver 盯着 #protected-content,等密文解开、HTML 注入后再跑一遍。
7.3 编辑器内也显示成卡片(ProseMirror 装饰)
前台有卡片了,但编辑器里还是一行字。解决办法是给 Milkdown 加一个 ProseMirror 装饰(decoration)插件:扫描”唯一子节点是一个指向 /files/uploads/ 的链接文本”的段落,给它挂一个节点装饰:
props: {
decorations(state) {
const decos = [];
state.doc.descendants((node, pos) => {
if (node.type.name !== 'paragraph' || node.childCount !== 1) return;
const link = node.firstChild.marks.find((m) => m.type.name === 'link');
const href = link?.attrs?.href;
if (!href?.includes('/files/uploads/')) return false;
decos.push(Decoration.node(pos, pos + node.nodeSize, {
class: 'milk-attach',
'data-icon': iconFor(name), // 🐍 / 📕 / 🗜️ ...
'data-size': sizeCache.get(href) || '',
}));
return false;
});
return DecorationSet.create(state.doc, decos);
},
}
它是纯视图层的——只加 class 和 data-* 属性,底层 Markdown 一个字都不改,所以保存/往返、“脏未保存”判定都不受影响。卡片样式用 CSS 的 ::before { content: attr(data-icon) } 和 ::after { content: attr(data-size) } 渲染。文件大小同样异步 HEAD 拿到后写进缓存,再 dispatch 一个 meta 事务让插件重算一次装饰、把大小补上。
7.4 首页重设计
把首页从”默认模板感”改成统一、克制的学术风:
- 配色:默认电光蓝
#2337ff→ 深 teal#0f766e(和后台一致);偏蓝的灰统一成中性灰;强调色只在链接 / 激活态 / 标签上克制使用,全站 token 收在global.css的:root。 - 字体:去掉网页字体,改用系统字体栈(含
PingFang SC/Microsoft YaHei等中文字体)。两个理由:原来的 Atkinson 不含中文字形,中文本来就在 fallback;而且服务器在国内,用 Google Fonts 这类外链字体可能拖垮构建——系统栈零加载、零风险、中英文一致。 - 结构:Hero 区结构化(头像 + 研究方向 + 名字 + 联系按钮);新增 Research Series 卡片网格,把”系列”从每行重复的元信息提升为首页主导航;文章列表用 teal 标签 pill 替代到处重复的蓝色 “Series:“。
八、工程化与踩过的坑
- 先校验后上线:改完先在服务器上构建,把
dist/拉回本地起一个静态服务、用 headless 浏览器截图确认(桌面 + 移动、首页/系列/列表/文章都看一遍),满意了才rsync到生产——生产不经历半成品状态。 - 改前必备份:动
server.mjs/ 模板前先打时间戳备份(.design-backups/<时间戳>/、*.bak-<时间戳>),误操作可秒回滚。 - 部署脚本的老 bug:编辑器的
deploy.sh里有一行ls public/assets,但文件其实是平铺在public/、根本没有assets/子目录;在set -e下这一行直接让脚本在”重启服务”之前就退出——表现就是”看似部署了、其实没重启”。删掉那行多余的ls就好了。 - 纯链接往返原则:附件不引入任何自定义 Markdown 语法,卡片和预览全部放在渲染期/视图层做,源文件始终是干净的标准 Markdown。这条原则让”编辑器里 / 前台 / 加密注入”三处可以各自增强,互不打架。
结语
整套东西的取舍可以一句话概括:能在构建期做的就不放到运行期,能放在渲染期/视图层的就不污染源数据。静态前台 + 本机后台 + 构建期加密,既保持了静态站的简单与安全,又通过一个手写后台拿到了”在线写作 / 上传 / 一键发布”的体验。
这篇会随站点继续更新。如果你也在用 Astro 搭个人站,希望这份手记能省下你几次踩坑的时间。