Files
cms-website/apps/api/src/modules/import/magic-bytes.ts
T
浅念 ed081ec477 feat(import): 媒体登记与安全校验
Plan 3 Task 4:media-writer(attachment 行三级对拍 + 内容行引用提取双事件消费)
+ magic-bytes 手写签名表(零新增依赖)。

- 三级对拍(前置事实 7):①uploads 相对路径清单精确命中 ②侧车 source_url→
  file_path→清单实体(精确 miss 按文件名候选,多候选歧义不自动选全列 issue)
  ③missing 只进报告不插行(sha256 NOT NULL 库层防线)
- 魔数校验:命中实体读存储副本头部实际字节——SVG 检出 status=quarantined 不进
  产物语义(引用不建 usage);未知签名 unsupported;扩展名声明 MIME 与检测不符
  → issue(按检测登记)
- usage 两阶段时序(WXR 文档序 items 先于 attachment):内容行提取引用 + 修订
  锚点(依赖 content-writer 先处理同事件——Task 6 编排顺序契约),finish 统一
  对 media_assets 反查(id→source_id / URL→source_url→public_path),顺序无关
- 悬空引用计数 mediaDanglingRefs 不静默(mini 203/204 钉死)
- counts 六键 media 前缀独占(勿复用 'skipped' 的键契约注记)
- 幂等:(site,source_url) 定向 onConflictDoNothing + usage 复合主键——重跑零增行

308 测试(296+6 单测+6 集成);typecheck/lint 三绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 18:38:10 +08:00

50 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 手写魔数签名表(Plan 3 Task 4,spec §11.1、计划"Tech Stack:零新增依赖"):
// 常见六类媒体格式判别 + SVG 文本特征检出。刻意不引 file-type 依赖——导入管道的
// 安全决策只须回答两问:"是什么格式"与"是否 SVG(隔离)",一张可审计的小签名表
// 足够,且避免为一次头部读取引入整个 MIME 数据库。
//
// 判别语义(detectMediaType):
// - 命中二进制签名 → {type: MIME, svg: false}(PNG/JPEG/GIF87a/GIF89a/WEBP/PDF);
// - 无二进制命中但前 1KB 文本窗口含 "<svg"(大小写不敏感)→ {type: 'image/svg+xml',
// svg: true}——调用方据此 status='quarantined'(spec §11.1:SVG 可携带脚本与外部
// 引用,不进产物语义);
// - 均未命中 → null(未知签名——调用方 status='unsupported' + issue,不猜格式)。
//
// 顺序纪律:二进制签名先于 SVG 文本扫描——PNG/JPEG 正例即便尾部字节碰巧含 "<svg"
// 片段也按二进制格式判(文本窗口只对非二进制文件有意义)。窗口 1KB 是钉死的折中:
// 覆盖常规 "<?xml …?><svg" 前导(声明/注释/doctype),不给深藏脚本的巨型前缀放行。
//
// Buffer 读取口径:latin1 单字节解码后小写化做包含检测——不做 UTF-8 多字节解码,
// 避免截断的字节序列被解码成意外字符。
/** 二进制签名表(前缀匹配;WEBP 的 RIFF+WEBP@8 双段结构在表外单查) */
const SIGNATURES: ReadonlyArray<{ mime: string; bytes: Buffer }> = Object.freeze([
{ mime: 'image/png', bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
{ mime: 'image/jpeg', bytes: Buffer.from([0xff, 0xd8, 0xff]) },
{ mime: 'image/gif', bytes: Buffer.from('GIF87a', 'latin1') },
{ mime: 'image/gif', bytes: Buffer.from('GIF89a', 'latin1') },
{ mime: 'application/pdf', bytes: Buffer.from('%PDF', 'latin1') },
])
const RIFF = Buffer.from('RIFF', 'latin1')
const WEBP = Buffer.from('WEBP', 'latin1')
const SVG_WINDOW_BYTES = 1024
/**
* 魔数检测:读文件头部字节判别媒体类型。返回 null = 未知签名(调用方按
* unsupported 处置);svg=true 仅出现在 type='image/svg+xml'(文本特征命中)。
*/
export function detectMediaType(buf: Buffer): { type: string; svg: boolean } | null {
for (const sig of SIGNATURES) {
if (buf.length >= sig.bytes.length && buf.subarray(0, sig.bytes.length).equals(sig.bytes)) {
return { type: sig.mime, svg: false }
}
}
// WEBP:RIFF 容器头(4B 长度)+ 'WEBP' Magic(@8)——RIFF 家族其他成员(WAV/AVI)不误报
if (buf.length >= 12 && buf.subarray(0, 4).equals(RIFF) && buf.subarray(8, 12).equals(WEBP)) {
return { type: 'image/webp', svg: false }
}
const head = buf.subarray(0, Math.min(buf.length, SVG_WINDOW_BYTES)).toString('latin1').toLowerCase()
if (head.includes('<svg')) return { type: 'image/svg+xml', svg: true }
return null
}