软件图标支持上传图片:存 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
+72
View File
@@ -149,6 +149,68 @@ function fb_attach_is_local($key) {
return strpos((string)$key, 'local/') === 0;
}
/* ==================================================================
* 软件图标(管理员上传的图片)
* ------------------------------------------------------------------
* 为什么不复用普通附件:
* 普通附件必须挂在某条 thread 下才允许被 media.php 读出(否则本站就成了
* 「能读整个桶」的开放代理)。软件图标不属于任何帖子,用它那套鉴权会被拒。
*
* 于是单独给一个键空间 `icons/`,并且:
* ① 只有管理员能写入(后台动作 + 登录校验);
* ② media.php 对 `icons/` 前缀只做「合法键 + 文件存在」判断,不做帖子鉴权;
* ③ 键的形状强约束成 icons/YYYY/MM/<16hex>.<ext>,无法穿越、无法指向别处。
* 图标本来就是公开信息(每个访客都能看到),不需要保密,只需要防写入。
*/
function fb_icon_key_prefix() {
return 'icons/';
}
/** 这个键是不是软件图标 */
function fb_icon_is_key($key) {
return strpos((string)$key, fb_icon_key_prefix()) === 0;
}
/** 生成一个图标对象键(始终为本机前缀,OSS 模式在写入时换前缀) */
function fb_icon_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
return fb_icon_key_prefix() . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/**
* 图标键是否合法。
* 与 fb_upload_key_ok 分开写:图标键不落在 local/ 也不落 oss.prefix 下,
* 塞进原函数会被判为不合法。
*
* 🔴 扩展名必须是白名单里的图片类型,不能只写 `[a-z0-9]{2,5}` ——
* 那样 `.php`(3 个字母)也会通过,等于给自己留了个写脚本的口子。
* 实际验证时就是这条先被漏掉了。
*/
function fb_icon_key_ok($key) {
$key = (string)$key;
if (strlen($key) < 16 || strlen($key) > 200) { return false; }
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
if (!preg_match('#^icons/\d{4}/\d{2}/[a-f0-9]{16}\.([a-z0-9]{2,5})$#', $key, $m)) { return false; }
return in_array(strtolower($m[1]), ['png', 'jpg', 'jpeg', 'webp'], true);
}
/**
* 图标图片的【对外地址】—— 一律走 media.php 代理。
* 与附件完全同一套约定:不暴露 uploads/ 真实路径,也不暴露任何 OSS 痕迹。
*
* @param string $key 图标对象键
* @param int $w 期望宽度(0=原图)。OSS 模式下交给服务端缩放;本机模式忽略。
* @return string 未经 HTML 转义的 URL(调用方自行 e() 转义)
*/
function fb_icon_url($key, $w = 0) {
$key = trim((string)$key);
if ($key === '' || !fb_icon_key_ok($key)) { return ''; }
if (!fb_attach_is_local($key) && function_exists('fb_oss_thumb_url')) {
return fb_oss_thumb_url($key, $w > 0 ? (int)$w : 96, 'image/png');
}
return fb_base_url() . '/media.php?k=' . rawurlencode($key);
}
/** key → 本机绝对路径(已做穿越防护) */
function fb_local_path_of($key) {
$rel = ltrim((string)$key, '/');
@@ -292,6 +354,16 @@ function fb_upload_key_ok($key) {
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
/* 扩展名必须是本站认可的类型。
* 🔴 原先本机分支写的是 `[a-z0-9]{2,5}`,`.php`(3 个字母)正好能过 ——
* 虽然真上传还有 finfo 读魔数那道关,但「键本身就不该长这样」,
* 不该把安全押在下一层校验上(换 nginx、改配置都可能绕过那层)。
* OSS 分支同理:键的扩展名也要过白名单,否则 media.php 会照扩展名片面给 Content-Type。 */
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'mp4', 'mov', 'webm'], true)) {
return false;
}
if (fb_attach_is_local($key)) {
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);