软件图标支持上传图片:存 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:
+206
-9
@@ -5,6 +5,123 @@
|
||||
*/
|
||||
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||
|
||||
/* ==================================================================
|
||||
* 软件图标图片:上传 / 删除
|
||||
* ------------------------------------------------------------------
|
||||
* 安全边界(三条,缺一不可):
|
||||
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
|
||||
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
|
||||
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
|
||||
* .htaccess 是唯一防线,换 nginx 就失守;
|
||||
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
|
||||
*
|
||||
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
|
||||
* 对外一律 media.php?k=... 代理,不暴露真实位置。
|
||||
* ================================================================== */
|
||||
|
||||
/** 图标允许的扩展名(比附件白名单窄:图标不需要 gif 动图,也不要 svg) */
|
||||
function fb_icon_allowed_ext() {
|
||||
// ⚠️ 刻意不收 svg:SVG 能内嵌 <script>,一旦被当图片直出到浏览器就是 XSS。
|
||||
// 图标是管理员自己传的,但防线不该依赖「管理员不会传坏东西」。
|
||||
return ['png', 'jpg', 'jpeg', 'webp'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存一个上传的图标文件,返回 [bool ok, string 值(键或错误信息), string mime]。
|
||||
* 成功后值是【对象键】;失败时值是给用户看的错误原因。
|
||||
*/
|
||||
function fb_icon_store_upload(array $file) {
|
||||
if (!isset($file['error']) || (int)$file['error'] !== UPLOAD_ERR_OK) {
|
||||
$code = isset($file['error']) ? (int)$file['error'] : -1;
|
||||
return [false, $code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE
|
||||
? '图片太大,请换一张小一点的(建议 512×512 以内)。'
|
||||
: '上传失败(错误码 ' . $code . ')。', ''];
|
||||
}
|
||||
$tmp = (string)(isset($file['tmp_name']) ? $file['tmp_name'] : '');
|
||||
if ($tmp === '' || !is_uploaded_file($tmp)) {
|
||||
return [false, '上传失败,请重试。', ''];
|
||||
}
|
||||
|
||||
$size = (int)@filesize($tmp);
|
||||
if ($size <= 0) { return [false, '图片是空文件。', '']; }
|
||||
if ($size > 2 * 1024 * 1024) { return [false, '图标不能超过 2 MB。', '']; }
|
||||
|
||||
// 真实类型(不信扩展名、不信前端上报的 mime)
|
||||
$mime = '';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = @finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($fi) { $mime = strtolower(trim((string)@finfo_file($fi, $tmp))); @finfo_close($fi); }
|
||||
}
|
||||
$mimeMap = [
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
if (!isset($mimeMap[$mime])) {
|
||||
return [false, '只支持 PNG / JPG / WEBP 格式的图片(检测到 ' . ($mime === '' ? '未知' : $mime) . ')。', ''];
|
||||
}
|
||||
$ext = $mimeMap[$mime];
|
||||
|
||||
// 尺寸下限:太小的图放大会糊,直接拒绝比事后难看更好
|
||||
if (function_exists('getimagesize')) {
|
||||
$wh = @getimagesize($tmp);
|
||||
if ($wh && isset($wh[0], $wh[1])) {
|
||||
if ($wh[0] < 32 || $wh[1] < 32) {
|
||||
return [false, '图片太小(' . (int)$wh[0] . '×' . (int)$wh[1] . '),至少 32×32。', ''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$mode = fb_store_mode();
|
||||
if ($mode === 'off') { return [false, '附件存储未启用,无法保存图标。请先在「站点设置」里启用存储。', '']; }
|
||||
|
||||
$key = fb_icon_make_key($ext);
|
||||
|
||||
if ($mode === 'local') {
|
||||
if (!fb_local_writable()) { return [false, '本机附件目录不可写(uploads/),无法保存图标。', '']; }
|
||||
$path = fb_local_path_of($key);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||
if (!is_dir($dir) || !@move_uploaded_file($tmp, $path)) {
|
||||
return [false, '保存图片失败,请稍后重试。', ''];
|
||||
}
|
||||
@chmod($path, 0644);
|
||||
} else {
|
||||
if (!fb_oss_enabled()) { return [false, '对象存储未配置完整,无法保存图标。', '']; }
|
||||
list($ok, $err) = fb_oss_put_file($key, $tmp, $mime);
|
||||
if (!$ok) { return [false, '写入对象存储失败:' . $err, '']; }
|
||||
}
|
||||
|
||||
return [true, $key, $mime];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个图标实体(换成新图 / 清空图标时调用,避免孤儿文件堆积)。
|
||||
*
|
||||
* @return bool 是否已确认删除(文件不存在也算成功)
|
||||
*
|
||||
* 🔴 不要用 @ 把错误吞掉:删失败却返回「已保存」,
|
||||
* 用户会以为旧图清掉了,实际 OSS 上还留着一个可访问的孤儿图。
|
||||
* 这里失败就返回 false,调用方据此提示。
|
||||
*/
|
||||
function fb_icon_delete($key) {
|
||||
$key = trim((string)$key);
|
||||
if ($key === '' || !fb_icon_key_ok($key)) { return true; } // 没东西可删
|
||||
|
||||
if (fb_attach_is_local($key)) {
|
||||
$p = fb_local_path_of($key);
|
||||
if (!is_file($p)) { return true; }
|
||||
return (bool)@unlink($p);
|
||||
}
|
||||
if (!fb_oss_enabled()) { return true; } // OSS 关掉了,对象已不可达,不必纠结
|
||||
if (!function_exists('fb_oss_delete')) { return false; }
|
||||
try {
|
||||
return (bool)fb_oss_delete($key);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用)
|
||||
* 返回 [消息, 类型],类型 ok / error
|
||||
@@ -31,8 +148,18 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,1,?)')
|
||||
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $dl, $sort, $now]);
|
||||
|
||||
// 新建时也可以直接带上图标图片(先入库,再更新 icon_key)
|
||||
$iconKeyNew = '';
|
||||
if (isset($_FILES['icon_file']) && is_array($_FILES['icon_file'])
|
||||
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
|
||||
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
|
||||
$iconKeyNew = $valUp;
|
||||
}
|
||||
|
||||
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,icon_key,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)')
|
||||
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $iconKeyNew, $dl, $sort, $now]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
// 新软件默认给一组常见分类,省得空着没法发帖
|
||||
$i = 0;
|
||||
@@ -58,10 +185,49 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, download_url=?, sort_order=? WHERE id=?')
|
||||
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $dl,
|
||||
|
||||
/* ---- 图标图片 ----
|
||||
* 三种情况:
|
||||
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
|
||||
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
|
||||
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
|
||||
* 这与「配置被跨页清空」是同一类错误) */
|
||||
$oldIconKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
|
||||
$iconKey = $oldIconKey;
|
||||
$delFailed = '';
|
||||
|
||||
$hasNewFile = isset($_FILES['icon_file'])
|
||||
&& is_array($_FILES['icon_file'])
|
||||
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
|
||||
|
||||
if ($hasNewFile) {
|
||||
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
|
||||
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
|
||||
$iconKey = $valUp;
|
||||
if ($oldIconKey !== '' && $oldIconKey !== $iconKey) {
|
||||
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
|
||||
}
|
||||
} elseif (!empty($post['icon_clear'])) {
|
||||
if ($oldIconKey !== '') {
|
||||
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
|
||||
}
|
||||
$iconKey = '';
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, icon_key=?, download_url=?, sort_order=? WHERE id=?')
|
||||
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $iconKey, $dl,
|
||||
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
|
||||
return ['软件「' . $name . '」已保存。', 'ok'];
|
||||
|
||||
$extraMsg = '';
|
||||
if ($hasNewFile) { $extraMsg = ',图标已更新'; }
|
||||
elseif (!empty($post['icon_clear']) && $oldIconKey !== '') { $extraMsg = ',图标已移除'; }
|
||||
|
||||
// 旧图删不掉要如实说 —— 悄悄留下一个还能访问的孤儿图是安全隐患
|
||||
if ($delFailed !== '') {
|
||||
return ['软件「' . $name . '」已保存' . $extraMsg . ',但旧图标文件删除失败('
|
||||
. $delFailed . '),它可能仍可被访问,建议手动到存储里删掉。', 'error'];
|
||||
}
|
||||
return ['软件「' . $name . '」已保存' . $extraMsg . '。', 'ok'];
|
||||
}
|
||||
|
||||
case 'sw_del': {
|
||||
@@ -77,6 +243,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
}
|
||||
$pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]);
|
||||
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
|
||||
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
|
||||
return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
|
||||
}
|
||||
|
||||
@@ -300,7 +468,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
if ($editSw > 0) { $esw = fb_software($editSw); }
|
||||
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
|
||||
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
|
||||
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" data-validate>';
|
||||
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" enctype="multipart/form-data" data-validate>';
|
||||
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
|
||||
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
|
||||
echo ' <div class="row2">';
|
||||
@@ -315,10 +483,39 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
. '<input type="text" name="note" maxlength="120" value="'
|
||||
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
|
||||
|
||||
// 图标:一个字符即可(emoji / 汉字 / 字母),配合主题色一起区分软件
|
||||
$curIcon = $esw ? (string)(isset($esw['icon']) ? $esw['icon'] : '') : '';
|
||||
/* ---- 图标 ----
|
||||
* 两种方式并存,图片优先:
|
||||
* ① 上传一张图片(存 OSS / 本机,前端经 media.php 代理显示)—— 推荐
|
||||
* ② 不传图片时用下面那个字符(emoji / 汉字 / 字母)兜底
|
||||
* 有了图片,列表、页头、邮件里全都自动换成图片,不用分别设置。 */
|
||||
$curIcon = $esw ? (string)(isset($esw['icon']) ? $esw['icon'] : '') : '';
|
||||
$curIconKey = $esw ? trim((string)(isset($esw['icon_key']) ? $esw['icon_key'] : '')) : '';
|
||||
$curIconUrl = $curIconKey !== '' ? fb_icon_url($curIconKey, 96) : '';
|
||||
|
||||
echo ' <div class="field"><span>图标图片<i class="hint">上传一张图(PNG/JPG/WEBP,≤2MB,方形最佳)。传了图片就优先用图片,不再用下面的字符</i></span>';
|
||||
echo ' <div class="icon-up">';
|
||||
echo ' <div class="icon-prev">';
|
||||
if ($curIconUrl !== '') {
|
||||
echo '<img src="' . e($curIconUrl) . '" alt="" width="48" height="48">';
|
||||
} else {
|
||||
list($pc) = $esw ? fb_software_colors($esw) : ['#0057B8'];
|
||||
echo '<span class="icon-prev-ph" style="background:' . e($pc) . '">'
|
||||
. e(fb_software_icon($esw ? $esw : ['name' => ''])) . '</span>';
|
||||
}
|
||||
echo ' </div>';
|
||||
echo ' <div class="icon-up-main">';
|
||||
echo ' <input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="iconFile' . ($esw ? (int)$esw['id'] : 'new') . '">';
|
||||
echo ' <i class="hint" style="margin-top:4px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
|
||||
if ($curIconUrl !== '') {
|
||||
echo ' <label class="check" style="margin-top:6px"><input type="checkbox" name="icon_clear" value="1"> 移除当前图片,改用下面的字符图标</label>';
|
||||
}
|
||||
echo ' </div>';
|
||||
echo ' </div>';
|
||||
echo ' </div>';
|
||||
|
||||
// 字符兜底:没有图片时用它
|
||||
$iconSug = ['📅', '🔐', '🔑', '📝', '🎵', '📷', '🌤', '🧭', '📦', '⚙️', '🗂', '💬'];
|
||||
echo ' <label class="field"><span>图标<i class="hint">一个字符:emoji 或汉字/字母。列表、页头与邮件里都会带上它</i></span>'
|
||||
echo ' <label class="field"><span>字符图标<i class="hint">没上传图片时用它,一个字符:emoji 或汉字/字母</i></span>'
|
||||
. '<input type="text" name="icon" maxlength="8" value="' . e($curIcon) . '" placeholder="例如 📅">'
|
||||
. '<span class="icon-presets">';
|
||||
foreach ($iconSug as $ic) {
|
||||
|
||||
Reference in New Issue
Block a user