给博客加密系统添加多文件类型支持
问题
在前几篇文章中,我们给博客搭建了一套端到端加密体系:Markdown 文本用 AES-256-GCM 加密,图片单独加密为 .enc 文件,浏览器端解密后渲染。这套流程对普通博文来说很完美,但它有一个根本限制——只处理 .md 文件。
实际使用中,很多内容天然不适合 Markdown:
- 论文草稿本身就是 PDF
- 实验报告用 Jupyter Notebook 展示最直观
- 技术分享的演示文稿是 PPTX
- 数学推导用 LaTeX 写的,转 Markdown 会丢格式
强行转 Markdown 会丢失排版和交互性,而把它们当附件又破坏了"在线阅读"的体验。我需要让加密系统原生支持这些文件类型。
设计思路:按类型分流
既然没法用一套流程处理所有格式,那就按类型分流。核心设计是:构建时根据文件类型做不同的预处理,统一加密为标准 JSON,但打上类型标记;浏览器端根据标记选择不同的渲染方式。
| 文件类型 | 扩展名 | 构建时操作 | 浏览器端渲染 |
|---|---|---|---|
| 文本类 | .md |
加密原始 Markdown | marked.js → HTML |
| 笔记本 | .ipynb |
转 HTML → 加密 | 直接注入 HTML |
| 原生 PDF | .pdf |
加密原始二进制 | Blob URL + <iframe> |
| 可转换类 | .tex .docx .pptx 等 |
转 PDF → 加密二进制 | 同 PDF |
关键点:所有类型共享同一套双层密钥体系(PBKDF2 + AES-256-GCM),改动只在预处理和渲染两端,加密核心保持不变。
构建时 (Node.js)
══════════════════════════════════════════════════════════════
.md ─────────────────────→ 加密文本 ─→ slug.json (type: markdown)
.ipynb ──→ convertNotebook → 加密 HTML ─→ slug.json (type: html)
.pdf ────────────────────→ 加密二进制 ─→ slug.json (type: pdf)
.tex ───→ pdflatex ──┐
.pptx ──→ soffice ───┤──→ 加密二进制 ─→ slug.json (type: pdf)
.docx ──→ soffice ───┘
浏览器端
══════════════════════════════════════════════════════════════
slug.json ──→ 用户输入密码 ──→ PBKDF2 解密 slot ──→ AES-GCM 解密
│
┌─────────────────────────────────────┼──────────────┐
▼ ▼ ▼
type: markdown type: html type: pdf
marked.parse() 直接注入 Blob URL
▼ ▼ ▼
渲染 HTML 渲染笔记本 <iframe>
构建端实现
文件发现:从 Markdown 到全类型
第一步是让构建脚本认识新文件。原来的 findMarkdownFiles 升级为 findEncryptableFiles:
const TEXT_TYPES = ['.md'];
const NOTEBOOK_TYPES = ['.ipynb'];
const PDF_TYPE = ['.pdf'];
const PDF_CONVERTIBLE = ['.pptx', '.docx', '.rtf', '.tex', '.odt', '.ods', '.odp'];
const ALL_SUPPORTED = [...TEXT_TYPES, ...NOTEBOOK_TYPES, ...PDF_TYPE, ...PDF_CONVERTIBLE];
扫描 _content/crypt-posts/ 目录时,匹配所有 ALL_SUPPORTED 扩展名的文件。
元数据来源:frontmatter vs .meta.json
Markdown 的加密配置写在 YAML frontmatter 里,这没问题。但二进制文件没有 frontmatter,所以我们约定:非 Markdown 文件用同名的 .meta.json 提供元数据。
_content/crypt-posts/gol/
├── alife-paper-draft.pdf # PDF 源文件
├── alife-paper-draft.meta.json # 元数据
├── 2026-02-25-cross-experiment-en.md # Markdown(frontmatter 内置)
.meta.json 的格式和 frontmatter 的字段一一对应:
{
"title": "论文草稿",
"date": "2026-02-26",
"encrypted": [{ "key": "gol", "hint": ["fenda"] }],
"series": "gol"
}
构建脚本的 getEncryptedMeta() 函数统一了两种来源的读取逻辑:
function getEncryptedMeta(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.md') {
// Markdown: 从 frontmatter 读取
const { frontmatter, body } = parseFrontmatter(fs.readFileSync(filePath, 'utf-8'));
if (!frontmatter.encrypted) return null;
return { encrypted: frontmatter.encrypted, body, ext };
}
// 其他文件: 从 .meta.json 读取
const metaPath = filePath.replace(new RegExp(`\\${ext}$`), '.meta.json');
if (!fs.existsSync(metaPath)) return null;
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
if (!meta.encrypted) return null;
return { encrypted: meta.encrypted, ext };
}
内容预处理分流
这是构建端最核心的改动——根据 ext 进入不同的处理分支:
if (TEXT_TYPES.includes(ext)) {
// Markdown: 处理图片引用 + 加密原始文本
const processedBody = processMarkdownImages(meta.body, filePath, articleKeyString, slugDir);
contentBuffer = Buffer.from(processedBody, 'utf-8');
contentType = 'markdown';
} else if (NOTEBOOK_TYPES.includes(ext)) {
// Notebook: 构建时转为 HTML,加密 HTML
const html = convertNotebook(filePath);
contentBuffer = Buffer.from(html, 'utf-8');
contentType = 'html';
} else if (PDF_TYPE.includes(ext)) {
// PDF: 直接加密原始二进制
contentBuffer = fs.readFileSync(filePath);
contentType = 'pdf';
} else if (PDF_CONVERTIBLE.includes(ext)) {
// LaTeX/Office → 先转 PDF → 再加密二进制
const pdfPath = convertToPdf(filePath);
contentBuffer = fs.readFileSync(pdfPath);
contentType = 'pdf';
}
格式转换
LaTeX 用 pdflatex,Office 文档用 LibreOffice headless 模式:
function convertToPdf(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.tex') {
execSync(`pdflatex -interaction=nonstopmode -output-directory="${tmpDir}" "${filePath}"`,
{ timeout: 60000, stdio: 'pipe' });
} else {
execSync(`soffice --headless --convert-to pdf --outdir "${tmpDir}" "${filePath}"`,
{ timeout: 60000, stdio: 'pipe' });
}
}
-interaction=nonstopmode 避免 LaTeX 编译出错时等待交互输入,--headless 让 LibreOffice 在没有 GUI 的环境中运行。
加密函数的变化
函数签名从专用变为通用:
// 旧: encryptPost(markdownBody, articleKey, passwords)
// 新: encryptContent(contentBuffer, articleKey, passwords, contentType)
输出 JSON 增加了 type 字段,浏览器端据此决定渲染方式:
{
"type": "pdf",
"content": { "iv": "...", "ciphertext": "..." },
"keys": [{ "salt": "...", "iv": "...", "data": "...", "tag": "..." }]
}
如果 type 缺失,客户端默认按 markdown 处理,兼容旧文章。
浏览器端实现
tryDecrypt() 返回内容类型
解密函数需要从加密 JSON 中读取 type 字段并原样返回。特别地,PDF 是二进制数据,不能用 TextDecoder 解码——直接返回 ArrayBuffer:
const contentType = encData.type || 'markdown';
if (contentType === 'pdf') {
return { rawBuffer: decrypted, contentKeyB64, contentType };
}
return { plaintext: new TextDecoder().decode(decrypted), contentKeyB64, contentType };
renderDecryptedContent() 按类型渲染
这个新函数是浏览器端的"调度中心":
function renderDecryptedContent(result, article) {
const type = result.contentType || 'markdown';
if (type === 'pdf') {
const blob = new Blob([result.rawBuffer], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
article.innerHTML = `<iframe src="${url}" style="width:100%;height:80vh;
border:1px solid #ddd;border-radius:6px;" type="application/pdf"></iframe>`;
return null;
}
if (type === 'html') {
article.innerHTML = encNotebookCss + result.plaintext;
return result.plaintext;
}
// 默认: markdown
article.innerHTML = marked.parse(result.plaintext);
return result.plaintext;
}
对于 PDF,使用 Blob + URL.createObjectURL 创建一个只存在于内存中的临时 URL。解密后的数据从未离开浏览器内存,维持了端到端的安全性。
Session Cache 支持 PDF 二进制
sessionStorage 只能存字符串,无法直接存 ArrayBuffer。对于 PDF,我们先编码为 Base64 再存入:
if (type === 'pdf') {
const bytes = new Uint8Array(result.rawBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
sessionStorage.setItem(key, JSON.stringify({
content: btoa(binary), contentType: 'pdf', ts: Date.now()
}));
}
读取时再解码回来:
if (cached.contentType === 'pdf') {
const binary = atob(cached.content);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
cachedResult.rawBuffer = bytes.buffer;
}
这样用户在同一会话中刷新页面时不需要重新输入密码。
构建效果
$ node _tools/build-encrypted-posts.js
Encrypting: posts/gol/2026-02-25-cross-experiment-en (.md)
-> encrypted image: gol/.../cross_competition_dynamics.png.enc
-> blog/encrypted/gol/2026-02-25-cross-experiment-en.json [markdown] (1 key slots)
Encrypting: posts/2026/2026-02-26-encryption-test-latex (.tex)
-> blog/encrypted/2026/2026-02-26-encryption-test-latex.json [pdf] (1 key slots)
Encrypting: posts/gol/alife-paper-draft (.pdf)
-> blog/encrypted/gol/alife-paper-draft.json [pdf] (1 key slots)
Encrypted 16 post(s).
LaTeX 文件在构建时被 pdflatex 编译为 PDF,加密后在浏览器端以 iframe 渲染,公式、排版完全保真。
小结
这次改动把加密系统从"只能加密 Markdown"扩展为"几乎能加密任何东西"。设计原则是预处理分流、加密统一、渲染分派:
- 构建时根据文件类型做不同的预处理(文本保持原样、笔记本转 HTML、其他转 PDF)
- 加密核心完全不变,所有类型共享 PBKDF2 + AES-256-GCM 双层密钥体系
- 浏览器端根据
type字段选择渲染方式(marked.js / 直接注入 / iframe)
底层的加密逻辑一行没改,安全性保持不变。新增的构建依赖(pdflatex、LibreOffice)只在有对应文件类型时才需要——如果你只用 Markdown 和 PDF,什么都不用装。