软件图标支持上传图片:存 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
+22
View File
@@ -149,6 +149,28 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
display:inline-flex; align-items:center; justify-content:center;
}
.icon-preset:hover{border-color:var(--brand); background:var(--brand-soft)}
/* ---------- 软件图标(上传图片) ---------- */
/* 图标图片:外框与字符版完全一致,换图不会让布局跳动 */
.sw-badge-img{
display:inline-block; border-radius:7px; object-fit:cover; flex:none;
box-shadow:0 1px 2px rgba(15,25,45,.18); background:var(--card);
}
.chip.on .sw-badge-img{box-shadow:0 0 0 1px rgba(255,255,255,.5)}
/* 后台图标上传行 */
.icon-up{display:flex; align-items:flex-start; gap:14px; flex-wrap:wrap; margin-top:6px}
.icon-prev{
width:56px; height:56px; flex:none; border-radius:12px; overflow:hidden;
border:1px solid var(--line); background:var(--card);
display:flex; align-items:center; justify-content:center;
}
.icon-prev img{width:100%; height:100%; object-fit:cover; display:block}
.icon-prev-ph{
width:100%; height:100%; display:flex; align-items:center; justify-content:center;
color:#fff; font-size:24px; font-weight:600;
}
.icon-up-main{flex:1; min-width:220px; display:flex; flex-direction:column}
.icon-up-main input[type=file]{font-size:13.5px; max-width:100%}
/* 选中态的 chip 里,图标底衬白、字符取软件色,避免同色糊在一起 */
.chip.on .sw-badge-ic{background:#fff !important; color:var(--brand) !important; box-shadow:none}
.chip.on .sw-badge-nm{color:#fff}
+206 -9
View File
@@ -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) {
+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);
+1 -1
View File
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__));
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 17); }
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 18); }
}
/* ------------------------------------------------------------------
+9 -3
View File
@@ -325,12 +325,18 @@ SQL
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
}
/* ---- 软件增加图标(多软件并存时靠「图标 + 名字 + 颜色」三重区分)----
* 存一个字符即可:优先 emoji(日历 📅 / 锁 🔐 / 密码 🔑),也接受单个汉字或字母。
* 不存图片路径,避免再管一套上传 / 存储 / 权限 —— 邮件里也用同一个字符渲染,
* 前端与邮件天然一致,不会出现「页面有图标、邮件没有」的割裂。 */
* icon 存一个字符(emoji / 汉字 / 字母)—— 轻量兜底,没有图片时用它;
* icon_key 存【上传图片的对象键】—— 有它就优先用图片。
* 图片走与附件相同的存储与代理(local/... 或 <prefix>/...),
* 前端只输出 media.php?k=... 代理地址,不暴露真实位置。
* 两者并存:先有字符兜底,用户上传图片后自动切到图片,删掉图片又退回字符,
* 任何时刻都不会出现「没有图标」。 */
if (!isset($swNames['icon'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN icon TEXT NOT NULL DEFAULT ''");
}
if (!isset($swNames['icon_key'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN icon_key TEXT NOT NULL DEFAULT ''");
}
/* ---- 软件增加下载地址(状态「已解决」的邮件里给用户一个去下新版的入口)---- */
if (!isset($swNames['download_url'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN download_url TEXT NOT NULL DEFAULT ''");
+19 -5
View File
@@ -191,13 +191,27 @@ if (!function_exists('fb_mail_sw_header')) {
$icon = fb_software_icon($sw);
$name = (string)$sw['name'];
/* 有上传的图标图片就用图片(管理员自己的 logo),没有才用「色块 + 字符」。
* 邮件客户端对 flex 支持参差,这里一律用 table + 内联样式;
* 图片必须给死宽高和 alt,否则 Outlook 里会撑变形。 */
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 60) : '';
$cell = '';
if ($imgUrl !== '') {
$cell = '<img src="' . fb_mail_esc($imgUrl) . '" alt="' . fb_mail_esc($name) . '"'
. ' width="30" height="30"'
. ' style="width:30px;height:30px;border-radius:8px;display:block;border:0;outline:none;text-decoration:none">';
} else {
$cell = '<div style="width:30px;height:30px;background:' . fb_mail_esc($c) . ';'
. 'border-radius:8px;text-align:center;line-height:30px;'
. 'font-size:15px;color:#ffffff;font-weight:700">'
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span></div>';
}
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' style="border-collapse:collapse"><tr>'
. '<td width="30" style="width:30px;height:30px;background:' . fb_mail_esc($c) . ';'
. 'border-radius:8px;text-align:center;vertical-align:middle;'
. 'font-size:15px;line-height:30px;color:#ffffff;font-weight:700">'
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span>'
. '</td>'
. '<td width="30" style="width:30px;vertical-align:middle">' . $cell . '</td>'
. '<td style="padding-left:9px;vertical-align:middle;font-size:15px;'
. 'font-weight:700;color:#1b2430;letter-spacing:.2px">'
. fb_mail_esc($name) . '</td>'
+14
View File
@@ -338,6 +338,20 @@ function fb_software_badge($software, $size = 'md') {
$box = isset($px[$size]) ? $px[$size] : 26;
$fnt = isset($fs[$size]) ? $fs[$size] : 14;
/* 有上传的图标图片就用图片(管理员自己传的 logo),
* 没有才退回「色块 + 字符」。两者的外框尺寸完全一致,
* 换用图片不会让布局跳动。 */
$imgKey = isset($software['icon_key']) ? trim((string)$software['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, $box * 2) : '';
if ($imgUrl !== '') {
return '<span class="sw-badge sw-badge-' . e($size) . '">'
. '<img class="sw-badge-img" src="' . e($imgUrl) . '" alt=""'
. ' width="' . $box . '" height="' . $box . '" loading="lazy"'
. ' style="width:' . $box . 'px;height:' . $box . 'px">'
. '<span class="sw-badge-nm">' . e($name) . '</span>'
. '</span>';
}
return '<span class="sw-badge sw-badge-' . e($size) . '">'
. '<span class="sw-badge-ic" aria-hidden="true" style="width:' . $box . 'px;height:' . $box
. 'px;background:' . e($c) . ';font-size:' . $fnt . 'px">' . e($icon) . '</span>'
+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'] : '';