上传只是开始:vibe 项目的图片处理管线实战
一张原图落盘之后才是真正的工程:本文带你用 sharp 搭起六阶段图片处理管线——校验、EXIF 转正、三档尺寸、WebP/AVIF 双格式、右下角水印,并守住像素炸弹与版本红线五条安全线。

先划三条线,免得你读完发现走错片场:本文不讲文件怎么传上来(那是《文件上传指南》的地盘:分片、断点续传、直传签名的事都在那篇);不讲图片存哪、CDN 怎么分发(见《文件存储与 CDN 指南》);不讲视频转码(见《视频处理管线指南》)。本文只回答一个问题:同一张原图落盘之后,如何切出三档尺寸、转出 WebP/AVIF、打上不挡脸的水印,并让 EXIF 不再把你的图转横。
为什么这件事值得单独写一篇?因为在 vibe coding 的工作流里,图片上传功能通常是第一个"看起来做完了"的模块:表单能选文件、进度条能走、图能显示——于是你宣布完工。三个月后,账单和客诉一起到来:用户手机拍的 12MB 原图被直接塞进列表页,首屏加载 8 秒;有人竖着拍的照片在详情页横了过来;运营同学说要在图上加品牌水印,你发现原图上直接盖字会把人脸盖住;更刺激的是,某天早上你收到安全通告,说你用的图片库版本有个 RCE 漏洞。上传只是开始,真正的工程量都藏在"落盘之后"。
本文的目标读者是正在给 vibe 项目做图片上传/展示功能的一人团队或独立开发者,技术栈以 Next.js / Node 全栈为主,没有专职后端。全文围绕一条可直接落地的管线展开:校验 → 转正 → 多尺寸 → 多格式 → 水印 → 落盘。每节末尾都有决策表或检查点,代码片段均为可直接运行的 Node + sharp 示例。sharp 是贯穿全文的工具选择——不是因为它时髦,而是因为它基于 libvips 做流式处理,内存占用远低于 ImageMagick / GraphicsMagick 那种"先把整图读进内存"的老派做法;对 serverless 和按请求计费的环境,这直接等于钱。
一、管线全景:一张上传图的旅程
把"图片处理"拆成一条管线,而不是散落在各处的工具函数,是本文最重要的观点。一张用户上传的原图,从落盘到可展示,依次经过六个阶段,每个阶段只干一件事,失败时你能精确定位:
- 校验:MIME 白名单、文件大小上限、像素数上限。注意顺序——像素数检查必须发生在任何解码之前(见第六节像素炸弹),这是安全红线不是性能优化。
- 转正:按 EXIF Orientation 把图转正,并把这个 tag 移除。这是整条管线里最容易被漏掉、后果最直观的一步(见第三节)。
- 多尺寸:从原图切出三档尺寸。本篇推荐的三档是 thumb 256px、card 768px、hero 1600px(按宽边计),理由在第二节展开。
- 多格式:每档尺寸转出 WebP(全量)与 AVIF(只给 hero),并保留 JPEG 做 fallback。省流的账在第四节算。
- 水印:品牌露出,不是防盗(防盗防爬是另一篇《水印与反爬指南》的事,分工见第五节)。
- 落盘:按
{id}_{w}x{h}.{fmt}命名,多版本文件一次写完,元数据入库。
管线定下来之后,第一个工程决策是:这六个阶段跑在什么时候?三种主流模式没有银弹,选错的代价各不相同:
| 模式 | 用户何时看到图 | 实现复杂度 | 适合谁 | 最大的坑 |
|---|---|---|---|---|
| 请求内同步 上传接口里直接转完再返回 | 上传完成即全尺寸可用 | 最低,一个函数 | 日活几百的 MVP、内部工具 | 大图加工拖慢响应;serverless 函数超时(常见 10–30 秒上限);并发一高就排队 |
| 队列异步 上传后扔进队列,后台 worker 转 | 秒到分钟级延迟,需状态机 | 中等(队列 + 状态 + 兜底) | 常规生产环境,一人团队的默认选项 | 必须做"加工中"状态的前端展示和失败兜底(见第七节),否则用户以为上传丢了 |
| 按需实时裁剪 imgproxy / thumbor 类服务,请求时现切现缓存 | 首次慢(现切),命中缓存后快 | 低(引入一个服务) | 尺寸组合长尾、CDN 边缘节点多的场景 | URL 签名没做好等于开放的图片处理代理,会被刷爆;计费模型要算清楚 |
给一人团队的判断建议:默认选队列异步。请求内同步在 MVP 期够用,但你迟早会遇到一张 20MB 的手机原图把接口拖到超时;按需裁剪很香,但签名和缓存 key 的设计本身就是一篇指南的体量。本文后续所有代码示例默认跑在"队列 worker"语境里,但逻辑本身三种模式通用。
最后,给"加工完成"一个可验收的定义,否则你和未来的自己会扯皮。建议写进你的任务状态机:三档尺寸 × 两套格式的全部文件均已落盘且可通过 CDN 访问,且宽高、格式、哈希等元数据已入库,才算 done。任何一档缺失都标记为部分完成,走第七节的兜底逻辑,而不是假装成功。
二、sharp 落地:三档尺寸与画质取舍
先回答"为什么是 sharp"。Node 生态里处理图片的老三样是 ImageMagick、GraphicsMagick 和 sharp。前两者是命令行封装:每次处理都要起进程、传文件,内存里是整张图的位图;sharp 底下是 libvips,走流式 pipeline,内存占用通常只有前者的几分之一,速度快 4–5 倍(这是 libvips 官方文档里的经典对比结论,量级上可信,具体倍数随图和机器浮动)。对 vibe 项目这意味着:在 512MB 内存的 serverless 函数里转一张手机原图,sharp 能活,IM 大概率被 OOM killer 带走。选型结论就一句话:2026 年的 Node 图片处理,默认 sharp,没异议。
三档尺寸的推荐值——thumb 256、card 768、hero 1600(按宽边像素计)——不是拍脑袋定的,各有对应的展示场景:
- thumb 256:列表缩略图、头像、搜索结果。256 宽在 2x 屏上对应 128 CSS 像素的展示位,覆盖绝大多数列表场景;再小(如 128)会在高清屏上发虚,再大则失去"缩略图省流"的意义。
- card 768:卡片、信息流、文章配图。这是用户实际消费内容的主力尺寸,768 宽在移动端全宽展示时刚好够用,桌面端卡片也够。
- hero 1600:详情页大图、灯箱、分享海报底图。1600 是"桌面端全宽勉强够用、再大则体积爆炸"的平衡点;原图如果本身不足 1600,不要放大(见下文
withoutEnlargement)。
核心代码长这样,注意三个细节:rotate() 无参数转正(第三节细讲)、withoutEnlargement: true 防止小图被拉大变糊、以及"先读 metadata 做像素检查"的顺序(第六节细讲):
import sharp from "sharp";
const MAX_PIXELS = 50_000_000; // 5000 万像素红线,见第六节
const SIZES = [
{ name: "thumb", width: 256 },
{ name: "card", width: 768 },
{ name: "hero", width: 1600 },
];
async function render(input, { width, format, quality }) {
let p = sharp(input)
.rotate() // 无参数:按 EXIF 自动转正,必须在转格式之前
.resize({ width, withoutEnlargement: true });
if (format === "webp") p = p.webp({ quality });
if (format === "avif") p = p.avif({ quality, effort: 4 });
if (format === "jpeg") p = p.jpeg({ quality, mozjpeg: true });
return p.toBuffer();
}
export async function processImage(input /* Buffer */, id) {
const probe = await sharp(input).metadata(); // 只读元数据,不解码像素
if ((probe.width ?? 0) * (probe.height ?? 0) > MAX_PIXELS) {
throw new Error(`image too large: ${probe.width}x${probe.height}`);
}
const out = {};
for (const { name, width } of SIZES) {
out[name] = {
webp: await render(input, { width, format: "webp", quality: 75 }),
// AVIF 编码慢 5–10 倍,只给 hero 预生成(见第四节)
...(name === "hero"
? { avif: await render(input, { width, format: "avif", quality: 50 }) }
: {}),
jpeg: await render(input, { width, format: "jpeg", quality: 80 }),
};
}
return out; // 调用方按 {id}_{w}x{h}.{fmt} 落盘(见第七节)
}
resize 的 fit 参数是第二个容易踩坑的地方。sharp 默认 fit: "cover"(等比缩放填满、居中裁剪),对风景图没问题,但对有人脸的头像/产品图,居中裁剪可能把脸裁掉。取舍表:
| fit | 行为 | 适合 | 坑 |
|---|---|---|---|
cover(默认) | 填满尺寸,超出裁掉 | 风景、banner、封面 | 居中裁剪可能裁掉人脸/主体 |
contain | 完整保留,加边框留白 | 产品图、证件类 | 留白条影响排版,需前端配合背景色 |
fill | 强行拉伸到尺寸 | 几乎不用 | 变形,用户一眼能看出来 |
inside / outside | 按宽/高中较小/较大者适配 | 需要"最长边不超过 N"的场景 | 输出尺寸不固定,缓存 key 要带实际宽高 |
如果你的场景有人脸,sharp 的 resize 支持 position: "attention"(基于显著性 heuristic 的智能裁剪,尽量保留"显眼"区域),聊胜于无,但别把它当人脸检测——它不知道什么是脸,只是猜"这块区域信息量大"。真正关键的业务图(用户头像),建议上传时就让用户自己裁(前端 cropper),而不是指望服务端猜。这是产品设计问题,不是参数问题。
quality 的起始值建议(盲测起点,不是标准答案):
| 格式 | 起始 quality | 备注 |
|---|---|---|
| JPEG(mozjpeg) | 80 | 开 mozjpeg: true,同画质体积更小;人像/产品图盲测起点 |
| WebP | 75 | 同观感下比 JPEG 省约 15–25%(下节有笔者实测数据) |
| AVIF | 50 | AVIF 的 50 约相当于 JPEG 的 75–80 观感;effort 从 4 起步再往上试。笔者未对 AVIF 做独立实测,请以你自己的样张盲测为准 |
方法论比数字重要:quality 没有放之四海皆准的值。正确做法是准备 10–20 张你站点的典型图(人像、产品、截图、风景各来几张),固定导出几档 quality,遮住文件名盲测"哪张不能接受",取"刚好能接受"的上一档。截图类图片(含大面积纯色和文字)是个特例:有损压缩容易在文字边缘出晕影,这类图优先考虑 PNG 或 WebP lossless,而不是一味调低 quality。
三、EXIF 方向坑:为什么手机拍的图到你网站上横过来了
这是 vibe 项目里最高频、最莫名其妙的客诉之一:"我明明是竖着拍的,传到网站上怎么横过来了?"根因不在你的代码,在手机厂商的偷懒:手机拍照时,传感器永远按横向记录像素,然后在 EXIF 里写一个 Orientation tag 告诉看图软件"你显示的时候转一下"。最常见的竖拍对应 Orientation = 6(顺时针转 90° 显示)。
浏览器的行为是分裂的:直接打开 JPEG 原图时,现代浏览器会读这个 tag 并正确旋转显示;但一旦你的管线动过这张图(sharp 默认会 strip 掉 EXIF),tag 没了,像素还是横着存的,图就横过来了。更隐蔽的是 <img> 和 CSS 背景、canvas、不同浏览器之间对这个 tag 的处理本就不一致——依赖 EXIF Orientation 做展示,是把正确性赌在每一环都守规矩上,而经验告诉我们总有一环不守规矩。
正确的修法只有一种:在管线入口就把像素真正转正,并把 tag 移除,之后所有环节看到的都是"方向正确、无 tag"的干净像素。sharp 对此有一行解法:
sharp(input).rotate() // 无参数:按 EXIF Orientation 自动转正,并移除该 tag
根据 sharp 官方文档(api-operation 章节):rotate() 不传参数时,会读取 EXIF Orientation 自动旋转到正确方向,并从输出中移除该 tag。传了角度参数则按角度转、不再读 tag。记住这个区别:无参数 = "帮我转正",有参数 = "听我的"。
顺序铁律:转正必须发生在转格式之前。原因是 WebP 的 EXIF 方向在各浏览器里的支持本就不一致(有专门的跨浏览器实测页面证实了这一点,不确定性至今存在),如果你先转了 WebP 再指望 tag 生效,等于把坑从 JPEG 搬到 WebP。本篇第二节代码里 .rotate() 紧跟在 sharp(input) 后面、.resize() 之前,就是这个顺序的体现。流水线写法建议先 metadata() 读一遍(顺手做像素炸弹检查,见第六节),再进转正:
const probe = await sharp(input).metadata();
console.log(probe.orientation); // 1–8,6 最常见(手机竖拍)
// 之后:sharp(input).rotate().resize(...) —— 转正先行,转格式在后
附 EXIF Orientation 8 种取值的完整对照(测试图集里要覆盖,见第八节清单第 12 项):
| 值 | 含义 | 等效操作 |
|---|---|---|
| 1 | 正常 | 无 |
| 2 | 水平翻转 | 左右镜像 |
| 3 | 旋转 180° | 上下颠倒 |
| 4 | 垂直翻转 | 上下镜像 |
| 5 | 先顺时针 90° 再水平翻转 | 转置类 |
| 6 | 顺时针 90°(手机竖拍最常见) | 向右转 90° |
| 7 | 先顺时针 90° 再垂直翻转 | 转置类 |
| 8 | 逆时针 90° | 向左转 90° |
本节检查点:① 管线入口是否有无参数 rotate();② 它是否在第一次转格式之前;③ 你的回归图集里有没有 8 种方向的样张。三个都是 yes,这个坑才算填上。
四、现代格式实战:WebP/AVIF 到底省多少流量
"上 WebP 省 30% 流量"这种话,听听就好。省多少取决于你的图(照片还是截图)、你的 quality 取值、以及你拿什么当基准。本文不给你一个放之四海的数字,给你一套可复现的对照方法,外加笔者实测的一组样例数字(诚实标注在前)。
方法论(10 分钟能跑完):取 10–20 张你站点的典型原图,对每张输出 JPEG q80(基准)、WebP q75、AVIF q50,记录体积;再做一次盲测确认三档观感可接受。省流比例 = 1 − 新格式体积 / JPEG 基准体积。注意两点:① 基准要固定(别拿 JPEG q95 当分母,那样谁都显得省很多);② 截图类图片单独一组测,它的压缩行为和照片完全不同。
笔者实测样例(样本:880×456 照片,PNG 原图 274,524 字节;工具:Pillow 10.2.0;仅为单样本示例,非普适结论):
| 格式 / 质量 | 体积(字节) | 相对 JPEG q80 |
|---|---|---|
| JPEG q80(基准) | 168,307 | — |
| JPEG q70 | 145,746 | −13% |
| WebP q80 | 160,102 | −5% |
| WebP q75 | 138,202 | −18% |
| WebP q60 | 119,684 | −29% |
AVIF 本篇未做独立实测(笔者环境缺少 AVIF 编码器)。社区与厂商的公开对照普遍给出"同观感下比 JPEG 省 30–50%"的区间,但请把它当传闻,用上面的方法拿自己的样张重测一遍再写进技术方案。观点先行:即使 AVIF 只能省 20%,对图片流量占大头的站点也值得做——只是做法要讲究(见下文"只给 hero 预生成")。
格式定下来之后是 fallback 链:不是所有浏览器都吃 AVIF,写死一种格式等于主动放弃一部分用户。两种主流写法:
写法 A:<picture> 多 <source>(前端声明式,推荐默认用这个):
<picture>
<source srcset="/img/abc_1600x900.avif" type="image/avif" />
<source srcset="/img/abc_1600x900.webp" type="image/webp" />
<img src="/img/abc_1600x900.jpg" alt="产品实拍图" loading="lazy" />
</picture>
写法 B:服务端按 Accept 头内容协商(适合 API 直出图片二进制的场景):
// Node 中间件片段
const accept = req.headers.accept || "";
const fmt = accept.includes("image/avif") ? "avif"
: accept.includes("image/webp") ? "webp" : "jpeg";
res.setHeader("Vary", "Accept"); // 关键:让 CDN 按 Accept 分开缓存
res.sendFile(`/img/abc_1600x900.${fmt}`);
决策建议:页面展示用写法 A,简单、可缓存、CDN 友好;只有当你做"一个 URL 返回图片"的 API(比如 OG 图、动态海报接口)时才用写法 B,且必须记得 Vary: Accept,否则 CDN 会把 AVIF 缓存喂给不支持的浏览器。
AVIF 的取舍是本节最实际的判断:AVIF 编码比 WebP 慢 5–10 倍(量级感受,非精确基准),全量预生成会把你的队列 worker 烘干。一人团队的务实策略是——只对 hero 图预生成 AVIF,thumb/card 只出 WebP + JPEG。理由:hero 图单张体积大、省流绝对值高,且数量少(通常一张内容配一张 hero);缩略图本来就小,AVIF 那点省量不值得烧 CPU。sharp 里用 effort 控制编码耗时(0 最快、9 最慢),从 4 起步,盲测通过就别再往上加。
最后是一个安全开关,放在本节是因为它和格式强相关:sharp.block() 可以禁用指定的 libvips 加载器。比如在 CVE-2026-84383 爆发、你暂时无法升级 sharp 的窗口期,一行代码先把 HEIF/AVIF 解码掐掉止血(细节见第六节):
import sharp from "sharp";
// 应急止血:禁止 libvips 加载 HEIF/AVIF(影响:AVIF 输出仍可编码,但含 HEIF 的输入会被拒绝)
sharp.block({ operation: ["VipsForeignLoadHeif"] });
本节检查点:① 你有没有用自己的样张跑过对照表;② fallback 链两种写法是否至少落地了一种;③ AVIF 是否只限 hero;④ Vary: Accept(如果用写法 B)。
五、水印:品牌露出,不挡脸
先分工,避免和另一篇指南打架:站内已有一篇《水印与反爬指南》,那篇讲的是防盗防爬——不可见水印、频控、盗链防护,目标是让爬虫难受。本文讲的是品牌露出——可见的 © 字样或 logo,目标是让转发出去的图还带着你的名字。两篇互补,本文不重复防爬内容。
选型先行,文字水印 vs 图片水印:
| 文字水印 | 图片水印(logo) | |
|---|---|---|
| 实现 | 服务端动态生成 SVG 文字,composite 叠加 | 准备一张透明底 logo PNG,按比例缩放后叠加 |
| 优点 | 零素材、可带动态文案(如用户名、日期) | 品牌一致性最好,设计可控 |
| 缺点 | 字体渲染依赖服务端字体,中英文混排要小心 | logo 文件本身要管理多尺寸;深色/浅色图各需一版 |
| 适合 | UGC 社区("© 用户名")、MVP 期 | 有品牌规范的产品、摄影/设计类站点 |
一人团队的默认答案:先上文字水印。它不需要设计师给你切 logo,不需要管理素材版本,一行 SVG 模板就能跑。等品牌稳定了再换图片水印,管线里的 composite 位置不用动。
sharp 的 composite 支持九宫格定位(gravity 参数),这是水印位置的全部词汇表:
| gravity | 位置 | 水印适用性 |
|---|---|---|
northwest / north / northeast | 左上 / 上中 / 右上 | 上半区通常是主体(人脸、天空),慎用 |
west / center / east | 左中 / 正中 / 右中 | 正中是"防盗式"大水印位,品牌露出别用 |
southwest / south / southeast | 左下 / 下中 / 右下 | 右下是默认答案:阅读动线终点,主体概率最低 |
"不挡脸"是本节的实战核心,四个技巧按重要性排序:
- 位置默认右下(
gravity: "southeast"),并留出边距(水印 SVG 本身带内边距,或 composite 时算好偏移)。 - 低透明度:文字
fill-opacity取 0.5–0.6,能看清但不抢戏。别用纯白不透明——在浅色图上糊成一团,在深色图上像牛皮癣。 - 字号随图缩放:水印字号 = 图片宽度 / 28(经验系数),thumb 图上自动变小,hero 图上自动变大。写死 24px 的字在 256px 缩略图上会占掉四分之一画面。
- 只给值得的图打:thumb 缩略图不打水印(太小,打了也看不清还影响识别);card 可选;hero 必打。水印是品牌税,别对每张图收税。
可直接跑的文字水印代码(注意 SVG 是服务端生成的可信模板,和"用户上传 SVG"完全是两回事——后者的风险见第六节;模板里的用户文案要做 XML 转义):
import sharp from "sharp";
function escapeXml(s) {
return s.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
function textWatermark(text, width) {
const fontSize = Math.max(12, Math.round(width / 28)); // 字号随图缩放
const pad = Math.round(fontSize * 0.8);
const svg =
`<svg width="${width}" height="${fontSize * 2 + pad}">` +
`<text x="${width - pad}" y="${fontSize + pad / 2}" text-anchor="end" ` +
`font-family="sans-serif" font-size="${fontSize}" ` +
`fill="white" fill-opacity="0.55">${escapeXml(text)}</text></svg>`;
return Buffer.from(svg);
}
export async function addWatermark(input, { text, width }) {
return sharp(input)
.rotate()
.resize({ width, withoutEnlargement: true })
.composite([{
input: textWatermark(text, width),
gravity: "southeast", // 右下角:默认答案
}])
.toBuffer();
}
本节检查点:① 水印文案是否经过 XML 转义;② 字号是否随图缩放而非写死;③ thumb 是否免打;④ 是否和《水印与反爬指南》的职责分清楚了(品牌露出归本文,防盗归那篇)。
六、安全红线:图片处理即攻击面
2026 年 10 月的安全事件给所有做图片功能的人上了一课:图片处理本身就是攻击面。10 月 14 日,Next.js 发布带外安全更新,修复了 CVE-2026-96889——一个经由图片优化 API 的 SVG 远程代码执行漏洞(CVSS 9.5),根因是 sharp 底下的 librsvg 存在 use-after-free,影响 Next.js 10.0.0 至 15.5.27 与 16.0.0 至 16.4.0,修复版本为 15.5.28 / 16.4.1。注意这条攻击链:攻击者上传一张精心构造的 SVG,你的 sharp 一解码,内存破坏就发生了——不需要登录,不需要你写错一行代码,用了一个有漏洞的图片库就中招。
本节是红线清单,按"先止血、再治本"排序:
红线 1:sharp 版本必须 ≥ 0.35.5。2026 年 9 月披露的 CVE-2026-84383 是 sharp 依赖的 libheif 的堆缓冲区溢出(CVSS 3.1 达 9.8),恶意构造的 HEIF/AVIF 文件可触发远程代码执行,影响 sharp < 0.35.4(修复随 libheif 1.23.2 / sharp 0.35.4+);而 10 月的 SVG 事件(CVE-2026-96889)则要求 sharp ≥ 0.35.5(该版本捆绑 librsvg 2.63.2)。所以实际的版本下限是 0.35.5,两个 CVE 一次覆盖。检查命令:
npm ls sharp # 看实际安装版本,别只看 package.json 里写的 ^0.35.0
npm audit # 旧锁文件会把你钉死在 vulnerable 的 0.34.x
最常见的翻车是:package.json 写了 "sharp": "^0.35.0",但 package-lock.json 是三个月前生成的,锁着 0.34.x——npm ci 永远装的是锁文件里的版本。修法:删锁重装或 npm update sharp,然后 npm ls sharp 确认。
如果因为某些原因(比如基础镜像、native 构建)暂时升不上去,sharp 提供了一个应急止血开关——sharp.block(),直接禁用指定的 libvips 加载器:
import sharp from "sharp";
// 止血(非根治):禁止加载 HEIF/AVIF,CVE-2026-84383 的利用链就断了
// 代价:用户上传的 HEIC/AVIF 会被直接拒绝,需在产品层给出提示
sharp.block({ operation: ["VipsForeignLoadHeif"] });
注意这只是止血:它挡的是 HEIF/AVIF 这条利用链,不挡 librsvg 那条。升版本才是根治,block 是给你争取时间的。
红线 2:像素炸弹(decompression bomb)必须在解码前拦。攻击者可以构造一张"文件很小、解开很大"的图片(比如声明 100000×100000 像素的 PNG,文件只有几十 KB),你的管线一解码,内存直接爆炸。防御的关键是顺序:先 metadata() 读宽高(它不解码像素,开销极小),像素数超限直接拒绝,绝不进入 resize。第二节代码里的 MAX_PIXELS 检查就是这个:
const MAX_PIXELS = 50_000_000; // 5000 万像素:手机 1 亿像素原图会被拦,按业务调
const probe = await sharp(input).metadata();
if ((probe.width ?? 0) * (probe.height ?? 0) > MAX_PIXELS) {
throw new Error("image too large");
}
5000 万这个数不是标准,是起点:普通手机照片(1200 万–5000 万像素)能过,恶意构造的亿级像素图被拦。根据你的业务调——头像场景甚至可以压到 1200 万。
红线 3:用户上传的 SVG,默认拒绝。CVE-2026-96889 之后,对 SVG 的态度应该是:除非业务必需,否则上传白名单里不要出现 image/svg+xml。如果业务确实需要用户传 SVG(如设计类站点),两条路:① 沙箱转 PNG——用 sharp 把 SVG 栅格化成 PNG 后再进管线(sharp ≥ 0.35.5 的 librsvg 已修复已知 UAF,但"SVG 是图灵完备的 XML"这个本质没变,纵深防御不亏);② 严格的 XML 清洗(去 script、去外链、去事件属性)。注意区分:第五节水印用的 SVG 是服务端生成的可信模板,和用户上传的 SVG 是两种东西,不要因为"水印也用 SVG"就放松对上传 SVG 的警惕。
红线 4:文件名清洗与 MIME 白名单。上传文件名永远不可信:../../../etc/passwd 式路径穿越、.jpg.php 双后缀、Unicode homoglyph 欺骗。做法:落盘文件名全部由服务端生成(第七节的命名规范天然免疫这一项),原始文件名只存元数据用于展示;MIME 类型以文件魔数(magic bytes)为准,不以扩展名和客户端声明的 Content-Type 为准。sharp 的 metadata().format 就是读魔数的结果,可靠。
红线 5:CSP 的 img-src。图片展示页的内容安全策略里,img-src 只放你自己的域名和 CDN 域名,别图省事写 *。这条防的不是图片处理漏洞,而是用户可控图片 URL 场景下的内容注入——属于纵深防御,顺手做了不亏。
本节检查点(上线前逐条打勾):① npm ls sharp ≥ 0.35.5;② 像素炸弹检查在 metadata() 之后、任何解码之前;③ SVG 上传默认拒绝(或走沙箱转 PNG);④ 落盘文件名服务端生成;⑤ MIME 以魔数为准。
七、命名、缓存与兜底:让 CDN 和未来的你都省心
多版本文件的命名是小事,但命名定得好,CDN 缓存和故障排查会顺很多。推荐规范:
{id}_{w}x{h}.{fmt}
// 示例:
// b7f2c1_256x144.webp 宽高取转正后的实际输出尺寸
// b7f2c1_1600x900.avif
// b7f2c1_1600x900.jpg fallback
三个设计理由:① 缓存 key 天然唯一——尺寸和格式都在文件名里,CDN 不用配复杂的 vary 规则(用 Accept 协商的写法 B 除外,那种必须配 Vary: Accept,见第四节);② 不可变——同一 id 的同一规格永远对应同一内容,重新加工就换 id(或加版本号后缀),CDN 可以放心设长缓存;③ 可读——凌晨三点排查问题时,看到文件名就知道这是哪张图的哪个版本,不用查数据库。
注意 {w}x{h} 取的是转正后的实际输出尺寸,不是原图尺寸。竖拍的图转正后宽高互换,如果还用原图尺寸命名,排查时会产生困惑。生成文件名的代码:
async function persistVariant(buffer, { id, width, format }) {
const meta = await sharp(buffer).metadata(); // 读实际输出尺寸
const key = `${id}_${meta.width}x${meta.height}.${format}`;
await storage.put(key, buffer); // 存储实现见《文件存储与 CDN 指南》
return key;
}
(存储用哪个 SDK、CDN 怎么配,是《文件存储与 CDN 指南》的地盘,本文只定命名,不越界。)
加工失败的兜底策略。一人团队没有 on-call 轮班,所以兜底必须自动化,分三层:
| 失败点 | 自动兜底 | 用户感知 |
|---|---|---|
| 队列 worker 单次加工失败 | 指数退避重试 3 次 | 无(图稍晚上线) |
| 重试耗尽仍失败 | 原图直出:把原图按原样发布,前端正常展示 | 图能看,加载慢一点 |
| 原图也坏了(解码都失败) | 标记 failed,告警(邮件/飞书机器人),人工介入 | 上传失败提示,请用户重传 |
"原图直出"是整套兜底里最重要的一行设计:加工是优化,不是正确性的前提。图转不出来,用户至少要能看到原图;图裂了,才是真正的 P0。对应的状态机建议:pending → processing → ready | degraded(原图直出) | failed,前端对 degraded 和 ready 一视同仁地展示,只在内部打标,方便你后续重跑加工。
本节检查点:① 文件名是否包含实际输出宽高与格式;② 同一规格是否不可变(重加工换 id);③ 失败时是否有"原图直出"兜底而非直接 500。
八、一人团队落地清单(12 项)
把前七节收敛成一张开工清单。建议打印出来贴在显示器边上,上线前逐项打勾:
- 转正:管线入口有无参数
rotate(),且在第一次转格式之前。 - 三档尺寸:256 / 768 / 1600(按宽边),
withoutEnlargement: true,小图不放大。 - 双格式:WebP 全量三档,AVIF 只给 hero,JPEG fallback 保留。
- 水印:右下角 + 透明度 0.5–0.6 + 字号随图缩放;thumb 免打;文案做 XML 转义。
- 像素上限:
metadata()先读宽高,超 5000 万像素(按业务调)直接拒绝,不进解码。 - 版本红线:
npm ls sharp确认 ≥ 0.35.5,锁文件也要验;升不上去先用sharp.block()止血。 - 命名:
{id}_{w}x{h}.{fmt},宽高取转正后实际值,同一规格不可变。 - 失败兜底:重试 3 次 → 原图直出(degraded)→ 告警人工介入;加工是优化,不是正确性前提。
- 耗时监控:记录每张图的加工耗时(p50/p99),AVIF 的 effort 调参看这个数,别凭感觉。
- 流量成本核算:按第四节的方法跑一遍对照表,把"省 X% 流量"换算成每月的 CDN 账单数字——这是你向自己证明这套管线值得的唯一方式。
- serverless 体积:sharp 含 native 依赖,打包体积约几十 MB;Lambda 用 layer、容器用多阶段构建只带 runtime 包,别把整套构建工具链塞进镜像。
- 回归图集:准备 8 张 EXIF 方向样张(1–8 全覆盖)+ 1 张超大像素图 + 1 张坏文件 + 1 张 SVG,每次改管线跑一遍,断言输出方向正确、超限被拒、坏文件走兜底。
最后说一句判断:图片管线是 vibe 项目里典型的"前期 invisible、后期 expensive"的模块。不做,三个月后你在 CDN 账单和客诉里还债;做重了,一人团队又被复杂度拖死。本文给的这套——六阶段管线、队列异步、三档双格式、右下角水印、五条安全红线——是"刚好够用"的版本。先让它跑起来,再按第十项的账单数字决定要不要加码。上传只是开始,但开始之后的事,值得一次把它做对。
评论 (0)
相关文章

客户把一行 script 粘进他的网站,你的聊天/预约/评价组件就要在别人的 DOM 里稳定运行:iframe + postMessage 协议、Shadow DOM 样式隔离、publishable key 与域名白名单、版本 pin 与性能预算——一套客户敢嵌入的 Widget 实战。

爬虫 24 小时批量搬走你的内容、图片和商品数据怎么办?从 Cloudflare 四层检测、JA3/JA4 握手指纹、抓爬检测规则,到签名 URL、水印取证与投入产出清单——一份算过账的中小站长防爬实战指南。

错误是最常被调用的接口资产:RFC 9457 标准错误体、UPPER_SNAKE_CASE 稳定错误码、HTTP 状态码决策表、i18n 文案工程、traceId 贯穿、前端判别式类型与 Agent 可执行错误契约——一套 5 年不腐烂的错误体系实战。