05 把 Markdown 项目 ZIP 导入并转换成 HTML

本章把上一章的安全读取能力放进实际业务:用户上传 Markdown 项目 ZIP,服务端转换 HTML 和静态资源,再交给 archiver 生成下载包。

1. 先定义项目合同,再写代码

不要让程序“猜测任意 ZIP 的意图”。新人项目可以先规定一种简单结构:

my-document.zip
├── README.md              # 默认入口
├── chapters/
│   └── install.md
└── assets/
    ├── architecture.png
    └── article.css

第一版合同可以是:

SVG、HTML 和 CSS 都比普通位图更复杂。SVG 可以包含脚本或外部资源,CSS 可能加载远程 URL。新人版本可以先拒绝它们,确认有业务需求后再增加专门的清洗策略。

2. 三种安全不能混为一谈

ZIP 路径安全
防止写出隔离目录、符号链接和资源耗尽

文件类型安全
决定业务接受哪些文件,并验证实际内容

HTML 内容安全
防止 Markdown 转换结果中的 XSS 和危险 URL

例如,assets/a.png 是安全相对路径,不代表文件内容一定是 PNG;文件确实是 PNG,也不代表 Markdown 转换器产生的全部 HTML 都安全。

3. 用 manifest 描述导入结果

manifest 是经过校验的内部清单,后续转换阶段只使用清单,不再直接相信原始 Entry。

type ImportedFileKind = "markdown" | "image";

interface ImportedFile {
  relativePath: string;
  absolutePath: string;
  kind: ImportedFileKind;
  size: number;
}

interface MarkdownProjectManifest {
  entryMarkdown: ImportedFile;
  markdownFiles: ImportedFile[];
  assetFiles: ImportedFile[];
  totalUncompressedSize: number;
}

manifest 带来几个好处:

注意不要把用户提供的绝对路径放进响应。absolutePath 只在服务内部使用。

4. 建议的导入阶段

阶段 A:接收
Multer 限制 ZIP 自身大小并写入临时文件

阶段 B:检查与提取
yauzl 逐个读取 Entry,校验路径、类型和资源上限

阶段 C:建立 manifest
确定 Markdown、资源文件和唯一入口

阶段 D:转换
读取允许的 Markdown,渲染并清洗 HTML,复制安全资源

阶段 E:打包
archiver 生成结果 ZIP

阶段 F:响应与清理
发送下载;无论成功、失败或客户端中断都清理临时目录

不要在看到第一个 README.md 时就开始向客户端输出 ZIP。后面的 Entry 仍可能是路径穿越或 ZIP bomb;一旦响应头和 ZIP 内容已经发送,就无法改成结构化 JSON 错误。

5. 文件类型白名单

扩展名白名单适合做第一道业务筛选:

import path from "node:path";

const markdownExtensions = new Set([".md", ".markdown"]);
const imageExtensions = new Set([
  ".png",
  ".jpg",
  ".jpeg",
  ".gif",
  ".webp",
]);

function classifyFile(relativePath: string): ImportedFileKind {
  const extension = path.posix.extname(relativePath).toLowerCase();

  if (markdownExtensions.has(extension)) return "markdown";
  if (imageExtensions.has(extension)) return "image";

  throw new Error("UNSUPPORTED_PROJECT_FILE");
}

但扩展名由用户控制。图片若会被公开访问,至少应检查 Magic Number;更严格的图片业务应使用成熟图片库完整解码并重新编码。不要通过:

fileName.endsWith(".png")

就断言文件内容一定安全。

对于 Markdown,应限制单文件大小并按 UTF-8 严格解码,避免把任意二进制加载成巨大字符串。

6. 如何确定入口 Markdown

可以采用清晰的优先级:

  1. 请求显式传入 entry,且它精确匹配 manifest 中的 Markdown;
  2. 根目录存在唯一的 README.md(比较时可忽略大小写,但不能容忍两个大小写变体);
  3. ZIP 中只有一个 Markdown 文件时使用它;
  4. 其他情况返回 MARKDOWN_ENTRY_AMBIGUOUS
function chooseEntryMarkdown(
  markdownFiles: ImportedFile[],
  requestedEntry?: string,
): ImportedFile {
  if (requestedEntry !== undefined) {
    const match = markdownFiles.find(
      (file) => file.relativePath === requestedEntry,
    );
    if (match === undefined) throw new Error("MARKDOWN_ENTRY_NOT_FOUND");
    return match;
  }

  const readmes = markdownFiles.filter(
    (file) => file.relativePath.toLowerCase() === "readme.md",
  );
  if (readmes.length === 1) return readmes[0];
  if (markdownFiles.length === 1) return markdownFiles[0];

  throw new Error("MARKDOWN_ENTRY_AMBIGUOUS");
}

请求里的 entry 同样是用户输入,必须先按 ZIP 相对路径规则规范化,不能接受服务器绝对路径。

7. 解析 Markdown 中的相对资源

chapters/install.md 中写着:

![流程](../assets/architecture.png)

资源路径应相对于当前 Markdown 所在目录解析,而不是相对于 ZIP 根目录盲目拼接:

import path from "node:path";

function resolveMarkdownResource(
  markdownRelativePath: string,
  resourceReference: string,
): string {
  if (/^(?:[a-z]+:)?\/\//i.test(resourceReference)) {
    throw new Error("EXTERNAL_RESOURCE_NOT_ALLOWED");
  }

  const withoutQuery = resourceReference.split(/[?#]/, 1)[0];
  const decoded = decodeURIComponent(withoutQuery);
  const markdownDirectory = path.posix.dirname(markdownRelativePath);
  const resolved = path.posix.normalize(
    path.posix.join(markdownDirectory, decoded),
  );

  if (
    resolved === ".." ||
    resolved.startsWith("../") ||
    path.posix.isAbsolute(resolved)
  ) {
    throw new Error("MARKDOWN_RESOURCE_PATH_INVALID");
  }

  return resolved;
}

随后必须确认 resolved 精确存在于 manifest 的资源集合中。不要把 Markdown URL 直接交给 fs.readFile()

真实工程还应处理:

对这些情况应制定合同,而不是静默猜测。

8. Markdown 转 HTML 后仍要防 XSS

Markdown 可以承载危险链接或原始 HTML。例如:

[点击](javascript:alert(1))

<img src=x onerror=alert(1)>

推荐两层处理:

  1. 若使用 markdown-it,默认关闭原始 HTML:html: false
  2. 对最终 HTML 使用 sanitize-html 白名单清洗。
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";

const markdown = new MarkdownIt({
  html: false,
  linkify: true,
  typographer: false,
});

function renderSafeMarkdown(source: string): string {
  const rendered = markdown.render(source);

  return sanitizeHtml(rendered, {
    allowedTags: [
      "h1", "h2", "h3", "h4", "h5", "h6",
      "p", "blockquote", "pre", "code", "ul", "ol", "li",
      "strong", "em", "del", "a", "img", "hr", "br", "table",
      "thead", "tbody", "tr", "th", "td",
    ],
    allowedAttributes: {
      a: ["href", "title"],
      img: ["src", "alt", "title"],
      code: ["class"],
    },
    allowedSchemes: ["http", "https"],
    allowProtocolRelative: false,
  });
}

配置必须和业务一致。如果最终 HTML 要引用包内相对图片,需要确认 sanitize-html 的 URL 策略保留经过重写的相对地址。若允许代码高亮的 class,应限制生成来源,不要开放任意 style

sanitize-html 不是文件解压安全工具,也不会检查图片真实格式;它只负责 HTML 内容边界。

9. 文件名编码与 Markdown 文本编码

这是两个不同问题:

ZIP Entry 文件名

yauzl 在 decodeStrings: true 时,根据 ZIP 标志等信息把名称解码为字符串。通常 UTF-8 名称体验最好,旧 ZIP 也可能使用 CP437。无法稳定解码、包含替换字符或规范化后冲突的名称,建议拒绝并提示用户重新用 UTF-8 打包。

Markdown 文件内容

第一版合同可以只接受 UTF-8。使用 TextDecoder 的严格模式检测非法字节:

const utf8Decoder = new TextDecoder("utf-8", {
  fatal: true,
});

function decodeMarkdown(buffer: Uint8Array): string {
  return utf8Decoder.decode(buffer);
}

不要静默用系统默认编码读取,否则中文乱码可能进入最终 HTML,且不同服务器表现不一致。

10. 转换输出结构

简单的结果 ZIP 可以采用:

result.zip
├── index.html
└── assets/
    └── architecture.png

在生成 HTML 前,把所有本地资源引用重写到输出结构。例如将:

![流程](../assets/architecture.png)

改成最终 HTML 所在位置可访问的:

<img src="assets/architecture.png" alt="流程">

服务器生成的输出名称和用户上传名称应分开。若需要保留原始名称,可在 manifest 中作为显示元数据保存,但不能跳过安全校验直接用作磁盘路径。

11. 失败、取消与清理

任何阶段失败都应让整个任务失败:

import { rm } from "node:fs/promises";

async function runImport(taskDirectory: string): Promise<void> {
  try {
    // 安全提取、构建 manifest、转换、打包
  } finally {
    await rm(taskDirectory, {
      recursive: true,
      force: true,
    });
  }
}

实际 Express 流程还应处理客户端中断:

req/res close
  → 触发 AbortController
  → zipFile.close()
  → 销毁当前 Entry 流
  → archive.abort()
  → 删除临时目录

客户端断开不会自动停止 Markdown 转换、文件写入或 archiver。清理逻辑要设计成幂等:重复执行不会删除其他任务目录,也不会因为文件已经不存在而再次失败。

推荐错误码:

MARKDOWN_ENTRY_NOT_FOUND
MARKDOWN_ENTRY_AMBIGUOUS
MARKDOWN_ENCODING_INVALID
MARKDOWN_RESOURCE_NOT_FOUND
MARKDOWN_RESOURCE_PATH_INVALID
EXTERNAL_RESOURCE_NOT_ALLOWED
UNSUPPORTED_PROJECT_FILE
HTML_SANITIZATION_FAILED
ARCHIVE_CREATION_FAILED

12. 一份适合新人实现的职责划分

先保持简单,不需要一开始引入复杂架构:

Controller
接收上传、设置下载响应、处理客户端断开

ZipImportService
使用 yauzl 检查并提取,返回 manifest

MarkdownService
选择入口、解析资源、渲染并清洗 HTML

ArchiveService
使用 archiver 打包 generated 目录

每层传递有类型的对象,不传整个 reqres。这样既方便测试,也避免底层转换函数意外发送 HTTP 响应。

复盘题

  1. 为什么在第一个 Markdown 转换成功后,仍不能立即开始向客户端发送结果 ZIP?
  2. manifest 与直接反复遍历解压目录相比,有哪些好处?
  3. chapters/a.md 引用 ../assets/a.png 时,资源路径应以哪里为基准解析?
  4. 为什么扩展名白名单、Magic Number 和 sanitize-html 解决的是不同问题?
  5. 为什么第一版服务可以拒绝 SVG 和 CSS,而不是一开始支持所有静态资源?
  6. html: false 后为什么仍建议清洗最终 HTML?
  7. ZIP 文件名编码与 Markdown 文件内容编码有什么区别?
  8. 客户端取消下载后,服务端需要显式停止和清理哪些对象?

官方参考