23 最终实战:安全 Markdown ZIP 转换服务
本章不给出完整实现,而是提供可执行需求、函数接口和验收清单。完成后应能解释每个完成事件和清理动作,而不是只看到一次正常请求成功。
1. 输入格式
示例 ZIP:
project/
├─README.md
├─guide/
│ ├─start.md
│ └─images/logo.png
└─reference/
└─api.md
输出:
project/
├─README.html
├─guide/
│ ├─start.html
│ └─images/logo.png
└─reference/
└─api.html
是否保留最外层 project/ 要成为显式配置,不能偶然取决于 Archiver 参数。
2. HTTP 契约
POST /api/markdown-exports
Authorization: Bearer <token>
Content-Type: multipart/form-data
短任务可直接返回 ZIP;推荐进阶设计:
{
"code": "OK",
"message": "任务已创建",
"data": {
"taskId": "...",
"status": "queued"
}
}
查询:
GET /api/markdown-exports/:taskId
GET /api/markdown-exports/:taskId/download
3. 类型骨架
export interface MarkdownExportLimits {
maxArchiveBytes: number;
maxEntryCount: number;
maxEntryBytes: number;
maxExtractedBytes: number;
maxMarkdownBytes: number;
maxPathLength: number;
}
export interface MarkdownExportContext {
taskId: string;
uploadedZipPath: string;
extractDirectory: string;
renderedDirectory: string;
temporaryZipPath: string;
finalZipPath: string;
limits: MarkdownExportLimits;
}
需要自行实现:
createTaskContext()
validateZipEntryPath()
extractZipSafely()
findMarkdownFiles()
renderMarkdownFile()
copyStaticAsset()
archiveDirectory()
publishResult()
cleanupTaskDirectory()
4. 功能要求
- 只接受一个 ZIP;
- 限制上传大小;
- 创建随机任务目录;
- 保留内部目录层级;
- 转换
.md,复制允许的静态资源; - 输出完整 ZIP 后再发布;
- 下载使用原始语义名称;
- 所有阶段记录 taskId 和耗时。
5. 安全要求
- 拒绝绝对路径、
..、NUL 和非标准分隔符; - 拒绝符号链接/硬链接;
- 拒绝重复 Entry 和跨平台大小写冲突;
- 限制 Entry 数量、单文件、解压总量和路径长度;
- 实际传输字节也要计数;
- Markdown HTML 必须清理危险内容;
- 不把原始文件名作为物理路径;
- 不在公开响应中泄漏绝对路径。
6. Stream 要求
- Entry 到文件使用 Promise
pipeline(); - 静态资源复制使用
pipeline(); - 结果下载使用
pipeline(); - 任一端报错后整体 Promise 必须结束;
- AbortSignal 能取消可取消阶段;
- 失败后删除临时半成品;
- 不用
data事件堆积未等待异步任务。
7. 并发要求
- ZIP Entry 第一版按顺序处理;
- Markdown 第一版顺序转换;
- 进阶版本最多 4 个文件并发;
- PM2 多进程下计算总任务并发;
- 同一个 taskId 不能由两个 Worker 同时发布。
8. 错误码建议
UPLOAD_REQUIRED
ARCHIVE_TOO_LARGE
INVALID_ZIP
INVALID_ZIP_ENTRY_PATH
ZIP_ENTRY_LIMIT_EXCEEDED
EXTRACTED_SIZE_LIMIT_EXCEEDED
MARKDOWN_TOO_LARGE
MARKDOWN_RENDER_FAILED
ARCHIVE_CREATE_FAILED
FILE_STORAGE_ERROR
TASK_NOT_FOUND
EXPORT_NOT_READY
项目约定业务错误 HTTP 200 时,Pino 必须记录 businessCode;响应头已经发出后的下载错误不保证还能返回 JSON。
9. 必测输入
正常多层目录 ZIP
空 ZIP
损坏 ZIP
只有目录没有文件
两个目录分别有 index.md
重复 Entry
../server.ts
/etc/passwd
C:\Windows\...
混合 / 和 \
大小写冲突 README.md/readme.md
单个超大 Markdown
大量小文件
高压缩比文件
Markdown 中 script 和 javascript: 链接
目标磁盘不可写
目标磁盘空间不足
客户端下载中断
任务处理中服务退出
10. 手工验收
curl -i \
-H "Authorization: Bearer $TOKEN" \
-F "file=@sample.zip" \
http://127.0.0.1:8080/api/markdown-exports
Windows PowerShell 中变量和路径语法不同,应按当前 Shell 调整,不要机械复制 Bash 续行符。
下载后检查:
ZIP 能正常打开
HTML 数量正确
目录层级正确
图片链接有效
代码高亮 span 含 class
危险 HTML 已清理
无任务临时文件泄漏
无持续句柄/内存增长
11. 自动化测试方向
- 路径验证纯函数单元测试;
- 构造小型恶意 ZIP 集成测试;
- 模拟 Readable/Transform/Writable 错误;
- 使用临时目录运行文件测试;
- 每个测试在
finally清理; - 测试并发发布冲突;
- 测试 AbortSignal;
- 测试日志不含 Authorization 和敏感文件名。
12. 最终复盘题
end、finish、close在这条链路分别属于谁?- yauzl、WriteStream、Archiver、HTTP Response 任一报错时销毁哪些资源?
- 为什么 ZIP Header 大小不能作为唯一限制?
- 为什么目录层级消失不一定是
archive.directory()的问题? - 怎样证明连续处理 100 次后没有明显资源泄漏?