头像上传 GIF 支持排查记录
问题:在
https://linux.sb/profile上传头像时,无法选择 GIF 文件;即使前端绕过限制直接上传,后端也会返回{"ok":0,"message":"请选择头像图片"}。
一、排查过程
1. 确认前端资源位置
站点页面引用的前端资源:
https://linux.sb/app/assets/index.js?v=v8.6.5https://linux.sb/app/assets/plugins.js?v=8baf4e8b040d
头像上传逻辑在 plugins.js 的 avatar_upload 模块中。
2. 定位第一道限制:文件选择框 accept
页面实际渲染的 HTML:
<label class="btn avatar-upload-select">上传头像
<input type="file" accept="image/jpeg,image/png,image/webp" data-avatar-upload-input="" hidden="">
</label>accept 中没有 image/gif,导致文件选择对话框直接过滤掉 GIF。
3. 定位第二道限制:前端强制转 JPEG
plugins.js 中保存按钮的处理逻辑:
save?.addEventListener('click', () => {
if (!image) return;
const output = document.createElement('canvas');
output.width = output.height = 200;
const out = output.getContext('2d');
out.fillStyle = '#fff'; out.fillRect(0, 0, 200, 200);
out.drawImage(image, -x / scale, -y / scale, canvas.width / scale, canvas.height / scale, 0, 0, 200, 200);
save.disabled = true;
output.toBlob(async blob => {
try {
if (!blob) throw new Error('头像生成失败');
const body = new FormData();
body.append('_csrf', csrf);
body.append('avatar_upload_action', 'upload');
body.append('avatar', blob, 'avatar.jpg'); // ← 硬编码 jpg
const response = await fetch(panel.dataset.uploadUrl, {method: 'POST', body, headers: {'X-Requested-With': 'XMLHttpRequest'}});
...
}, 'image/jpeg', .9); // ← 强制转 jpeg,丢失 GIF 动画
});
});这里做了两件事:
- 用 Canvas 裁切/缩放头像
- 强制输出为
image/jpeg,文件名硬编码为avatar.jpg
后果:即使前端能选中 GIF,经过 Canvas 后也会变成静态 JPEG,动画帧全部丢失。
4. 前端 bypass 尝试
尝试在页面注入 JS 绕过 Canvas 裁剪,直接将原始 GIF 上传:
(() => {
const panel = document.querySelector('[data-avatar-upload]');
const input = panel.querySelector('[data-avatar-upload-input]');
const csrf = panel.querySelector('input[name=_csrf]')?.value || '';
const uploadUrl = panel.dataset.uploadUrl;
// 放开 accept
input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp');
// 拦截 change,GIF 直接上传
document.addEventListener('change', (event) => {
const file = input.files?.[0];
if (!file || !(file.type === 'image/gif' || file.name.toLowerCase().endsWith('.gif'))) return;
event.stopImmediatePropagation();
const body = new FormData();
body.append('_csrf', csrf);
body.append('avatar_upload_action', 'upload');
body.append('avatar', file, file.name);
fetch(uploadUrl, {method: 'POST', body, headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(r => r.json())
.then(data => { if (data.ok) window.location.href = data.redirect; });
}, true);
})();前端 patch 生效后,文件选择框已能选中 GIF,请求也能发出。
5. 后端拒绝:{"ok":0,"message":"请选择头像图片"}
绕过前端后,后端返回:
{"ok":0,"message":"请选择头像图片"}说明后端也存在文件类型校验,拒绝了非 JPEG/PNG/WebP 的文件,GIF 在后端也被视为无效头像格式。
二、结论
| 层级 | 限制 | 说明 |
|---|---|---|
| 前端 | accept 属性 | 过滤掉 GIF,不让用户选中 |
| 前端 | Canvas 裁剪 | 强制转 JPEG,GIF 动画丢失 |
| 后端 | 文件类型校验 | 拒绝 GIF 格式,返回"请选择头像图片" |
当前站点(v8.6.5)从头到尾都不支持 GIF 头像,三处都需要修改才能真正支持。
三、如果要支持 GIF,需要改哪些地方
3.1 前端 plugins.js — avatar_upload 模块
// 1. input accept 加上 gif
input.setAttribute('accept', 'image/jpeg,image/png,image/gif,image/webp');
// 2. 保存时保留原始格式,不要强制转 jpeg
save?.addEventListener('click', () => {
if (!image) return;
const output = document.createElement('canvas');
output.width = output.height = 200;
const out = output.getContext('2d');
out.fillStyle = '#fff'; out.fillRect(0, 0, 200, 200);
out.drawImage(image, -x / scale, -y / scale, canvas.width / scale, canvas.height / scale, 0, 0, 200, 200);
save.disabled = true;
// 根据原始文件决定输出格式
const mime = (file && file.type) ? file.type : 'image/jpeg';
const extMap = {'image/jpeg':'jpg','image/png':'png','image/gif':'gif','image/webp':'webp'};
const ext = extMap[mime] || 'jpg';
output.toBlob(async blob => {
try {
if (!blob) throw new Error('头像生成失败');
const body = new FormData();
body.append('_csrf', csrf);
body.append('avatar_upload_action', 'upload');
body.append('avatar', blob, 'avatar.' + ext);
const response = await fetch(panel.dataset.uploadUrl, {method: 'POST', body, headers: {'X-Requested-With': 'XMLHttpRequest'}});
const data = await response.json();
if (!data.ok) throw new Error(data.message || '头像上传失败');
window.location.href = data.redirect || window.location.href;
} catch (error) {
setStatus(error?.message || '头像上传失败', true);
} finally { save.disabled = false; }
}, mime, 0.92);
});注意:Canvas 本身不支持动画帧,上述改法上传的 GIF 会是静态帧。要支持动态 GIF 需要额外引入 GIF 帧解析/合成方案(如
gif.js、omggif等),改动量较大。
3.2 后端 — 头像上传接口
需要放宽文件类型校验,允许 image/gif 通过。
3.3 存储与展示
- 存储:保持原样,文件系统 / OSS 存原始文件即可
- 展示:确保页面
<img>标签能正常渲染 GIF(浏览器原生支持,通常无需改动)
四、关于 Canvas 裁剪的必要性说明
站点在头像上传中使用 Canvas 进行裁剪/缩放,这一设计是必要的,原因如下:
4.1 统一头像尺寸
Canvas 将任意尺寸的原始图片统一输出为 200×200 的正方形头像。如果没有这一步:
- 用户上传 4K 大图 → 头像区域显示异常,浪费带宽
- 用户上传超长条图 → 页面布局被撑开
- 各用户头像尺寸不一 → 列表/卡片对齐混乱
4.2 服务端降本
统一尺寸后,服务器可以对头像文件进行:
- 压缩(减小存储和 CDN 流量)
- 生成多档位缩略图(列表用小图,详情用大图)
- 缓存策略优化(固定尺寸便于 CDN 缓存)
如果允许任意尺寸原图直接存储,服务端需要做实时裁切/缩放,增加 CPU 和内存开销。
4.3 安全考虑
Canvas 处理可以:
- 过滤潜在恶意构造的图片(虽然不能完全替代服务端校验,但作为第一道防线有意义)
- 去除 EXIF 信息(避免泄露用户隐私数据)
4.4 交互一致性
Canvas 裁剪提供了统一的用户体验:
- 拖拽调整位置
- 缩放控制
- 实时预览最终效果
4.5 为什么不接受 GIF
在当前的 Canvas 裁剪方案下,GIF 动画帧会被丢弃,因为:
drawImage()只绘制当前帧toBlob()输出单帧静态图- 输出格式被固定为
image/jpeg
如果要支持动态 GIF,需要在 Canvas 之外额外实现:
- GIF 帧解析(
omggif/gifuct-js) - 帧合成/循环播放
- 或放弃 Canvas 裁剪,改用纯前端预览 + 后端处理
这属于需求层面的取舍,不是技术 impossibility。
五、后续建议
- 短期:如需临时支持 GIF,可手动修改本地
plugins.js缓存(浏览器 DevTools → Sources → Overrides),加上上述 accept + 保留原格式的 patch - 中期:向站点维护者反馈,在
plugins.js和头像上传接口中统一放开 GIF 支持 - 长期:如果要支持动态 GIF 头像,需要重新设计前端裁剪方案,引入 GIF 帧处理库