软件图标支持上传图片:存 OSS/本机,media.php 代理读取

用户要求图标可以自己上传一张真实图片,而不是只能填一个字符。

设计:字符和图片并存,图片优先
- software 加 icon_key(图片对象键),icon(字符)保留作兜底
- 有图片用图片,删掉图片自动退回字符,任何时刻都不会没有图标
- 前端 badge 与邮件头部同一套判断,页面和邮件永远一致

存储与读取
- 图标走独立键空间 icons/YYYY/MM/<16hex>.<ext>
  不复用附件那套:附件必须挂在 thread 下才给读,图标不属于任何帖子
- 写:仅管理员(后台动作链)+ finfo 真实类型校验 + 尺寸下限 + 2MB 上限
- 读:media.php 对 icons/ 只校验键形状与文件存在(图标本就公开),
  写入侧仍然严格,不会让本站变成能读整个桶的开放代理
- 优先用 media.php?k= 代理;OSS 下顺便借图片处理缩放,省下行

顺手修掉两个真实安全问题
1. fb_icon_key_ok 原来用 [a-z0-9]{2,5} 匹配扩展名,.php 正好能过
   (3 个字母)—— 测试反向验证时发现,已改为图片扩展名白名单
2. fb_upload_key_ok 有同样的问题,附件键也能构造出 .php
   虽然真上传还有 finfo 兜底,但键本身就不该长这样,一并收紧
3. fb_icon_delete 原来用 @ 吞掉错误,删除失败仍返回「已保存」,
   用户会以为旧图清了、实际留了个可访问的孤儿图。改为返回 bool 并如实提示

验证:键校验 7 类非法输入全拒;图标写入 OSS 成功、media.php 读回 200 image/png、
PNG 魔数正确、缩放生效;越权读取(穿越/.php/未入库附件/不存在)全部按预期拒绝;
表单 enctype/文件框/accept/预览渲染齐全;仿真 PHP 伪图与 SVG 均被拒;
16x16 小图被拒。
This commit is contained in:
2026-11-03 08:13:21 +08:00
parent 738c6c09ff
commit 835c8c05da
8 changed files with 377 additions and 21 deletions
+34 -3
View File
@@ -28,7 +28,11 @@ function fb_media_fail($code, $msg) {
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
if (!fb_upload_key_ok($key)) { fb_media_fail(400, '附件标识不合法'); }
/* 合法的键有两种:普通附件(local/… 或 <oss.prefix>/…)与软件图标(icons/…)。
* 图标有自己的键空间与校验规则,不能塞进 fb_upload_key_ok 里判断(它只认附件前缀)。*/
if (!fb_upload_key_ok($key) && !fb_icon_key_ok($key)) {
fb_media_fail(400, '附件标识不合法');
}
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
@@ -41,6 +45,31 @@ if ($process !== '') {
}
}
/* ---------------- 软件图标:独立的公开读取通道 ----------------
* 图标不属于任何帖子,用它那套「帖子可见才给看」的鉴权会被一律拒绝。
* 图标本身是公开信息(每个访客都要看到),所以这里只校验:
* ① 键的形状合法(icons/YYYY/MM/<hex>.<ext>,无法穿越);
* ② 文件确实存在。
* 写入侧仍然是严格的:只有登录管理员、且经过真实 MIME 校验才能落到 icons/ 下。
* 也就是说「读公开、写受控」,不会让本站变成能读整个桶的开放代理。 */
if (fb_icon_is_key($key)) {
if (!fb_icon_key_ok($key)) { fb_media_fail(400, '图标标识不合法'); }
if (fb_attach_is_local($key)) {
$path = fb_local_path_of($key);
if (!is_file($path)) { fb_media_fail(404, '图标不存在'); }
$size = (int)@filesize($path);
$mime = fb_media_local_mime($path, $key);
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('Cache-Control: public, max-age=604800'); // 图标变动很少,缓存一周
header('X-Content-Type-Options: nosniff');
fb_media_send_local($path, $size);
exit;
}
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
}
/* ---------------- 本机存储:直接读磁盘并流式输出 ----------------
* 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */
if (fb_attach_is_local($key)) {
@@ -60,8 +89,10 @@ if (fb_attach_is_local($key)) {
exit;
}
/* ---------------- OSS 存储 ---------------- */
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
/* ---------------- OSS 存储 ----------------
* 图标已在上面单独鉴权过(读公开、写受控),到这里就不再走帖子可见性判断;
* 普通附件仍然必须过 fb_media_authorized —— 否则本站会变成能读整个桶的开放代理。 */
if (!fb_icon_is_key($key) && !fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';