← Back to Blog
EN中文

给博客加密系统添加多文件类型支持

问题

在前几篇文章中,我们给博客搭建了一套端到端加密体系: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"扩展为"几乎能加密任何东西"。设计原则是预处理分流、加密统一、渲染分派

  1. 构建时根据文件类型做不同的预处理(文本保持原样、笔记本转 HTML、其他转 PDF)
  2. 加密核心完全不变,所有类型共享 PBKDF2 + AES-256-GCM 双层密钥体系
  3. 浏览器端根据 type 字段选择渲染方式(marked.js / 直接注入 / iframe)

底层的加密逻辑一行没改,安全性保持不变。新增的构建依赖(pdflatex、LibreOffice)只在有对应文件类型时才需要——如果你只用 Markdown 和 PDF,什么都不用装。