Files
FeedLite/lib/attach.php
T
laoyang 669ab9e43e 修复图标显示不出来;标题改XX反馈系统;图标去掉背景色
一、图标显示不出来(根因)
用户上传图标时用的是修复前的代码,数据库里 icon_key 存的是
icons/2026/11/xxx.png —— 没有 local/ 也没有 oss.prefix 前缀。
上一轮我把键校验收紧成「必须带存储前缀」,于是这些键被判非法,
fb_icon_url() 返回空字符串,图标自然显示不出来。
而三个文件其实都真实存在于 OSS 上(13KB / 391KB / 379KB)。

修法两层:
1. fb_icon_key_ok / fb_icon_is_key 兼容历史无前缀形态,
   这些是有效资产,不能因为键格式不规范就让图标消失
2. 数据迁移:把对象复制到 feedlite/icons/… 并把数据库键改成规范形态,
   已对三个软件全部执行并验证(新键 200,大小与源文件一致)

二、标题改为「XX 反馈系统」
- 页头大标题 = 同步日历反馈系统(之前只有「同步日历」)
- 浏览器标签同步改
- 软件名本身含「反馈」时不叠加,避免「轻反馈反馈系统」

三、图标不要背景色
- 去掉色块底衬与圆角投影,直接「图标 + 文字」并排
- 前端 badge、提交页上下文条、邮件头部三处同步
- md 尺寸调到 28px,与 26px 标题文字视觉匹配

验证:三个软件页标题正确、图标全部 200 image/png、
全部页面 200、迁移后新键可读且大小一致。
2026-11-03 08:27:03 +08:00

786 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 附件(图片 / 视频)—— 校验、入库、查询、渲染、过期清理
* ------------------------------------------------------------------
* 设计要点:
* - 文件实体只存在 OSS,本地库只存元信息(对象键、大小、类型、尺寸)。
* - 客户端提交的只是「描述」,服务端必须 HEAD 回源复核,防止伪造或超限。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 读取 upload 配置 */
function fb_upload_cfg($k = null, $d = null) {
$c = (array)fb_cfg('upload', []);
if ($k === null) { return $c; }
return array_key_exists($k, $c) ? $c[$k] : $d;
}
/* ==================================================================
* 存储方式:off(关闭)| local(存本机磁盘)| oss(存阿里云 OSS)
* 同一条附件走哪种存储,由它的 key 前缀决定:
* local/... → 本机 uploads/ 目录 fb/... → OSS
* 这样中途改存储方式也不会让旧附件失效。
* ================================================================== */
/** 当前存储方式(配置 attachment.storage;未配置则按 oss.enabled 推断) */
function fb_store_mode() {
$m = strtolower(trim((string)fb_cfg('attachment.storage', '')));
if ($m === 'local' || $m === 'oss' || $m === 'off') { return $m; }
return fb_oss_enabled() ? 'oss' : 'off';
}
/** 本机附件目录(站点内可直连访问) */
function fb_local_dir() {
return rtrim(str_replace('\\', '/', FB_ROOT . '/uploads'), '/');
}
/**
* 确保本机附件目录存在,并补齐安全防护文件(.htaccess / index.html)。
* ⚠️ 目录被删掉后由 PHP 重建时,.htaccess 不会自动回来 —— 必须在这里补写,
* 否则「禁列目录 + 禁执行脚本」的防护就丢了。
*
* ⚠️⚠️ 【版本化升级】只判「文件在不在」是不够的:老站点上早已存在的
* 旧版 .htaccess(允许直读)会一直留着,新规则永远写不进去。
* 所以规则串里埋一行 `# fb-local-guard: vN`,每次确保时比对版本号,
* 低于当前版本就整份重写。改规则时务必把 FB_LOCAL_GUARD_VER 一起 +1。
*/
define('FB_LOCAL_GUARD_VER', 2);
function fb_local_guard_rule() {
return "# 本机附件目录(由程序自动生成)\n"
. "# fb-local-guard: v" . FB_LOCAL_GUARD_VER . "\n"
. "# ★ 一律禁止 Web 直接访问:附件只走 media.php 代理,真实路径不暴露\n"
. "Options -Indexes\n\n"
. "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n\n"
. "<FilesMatch \"\\.(php|php[0-9]|phtml|pht|phar|pl|py|cgi|sh|htaccess)$\">\n"
. " <IfModule mod_authz_core.c>\n Require all denied\n </IfModule>\n"
. " <IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n </IfModule>\n"
. "</FilesMatch>\n";
}
/** 确保目录存在并补齐防护文件(规则版本见上方 FB_LOCAL_GUARD_VER) */
function fb_local_ensure_dir() {
$dir = fb_local_dir();
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
if (!is_dir($dir)) { return false; }
@chmod($dir, 0755);
}
$ht = $dir . '/.htaccess';
if (!is_file($ht) || fb_local_guard_stale($ht)) {
@file_put_contents($ht, fb_local_guard_rule());
@chmod($ht, 0644);
}
$idx = $dir . '/index.html';
if (!is_file($idx)) {
@file_put_contents($idx, '');
@chmod($idx, 0644);
}
return true;
}
/**
* 现存的 .htaccess 是不是旧版(需要升级)。
* 判据:拿不到 `fb-local-guard: vN` 标记,或标记里的 N < FB_LOCAL_GUARD_VER
* → 都算过期,整份重写(旧文件没有标记,正是最需要升级的那种)。
* 只在目录可写时才重写,避免不可写环境下每次都白试一遍。
*/
function fb_local_guard_stale($ht) {
$body = @file_get_contents($ht, false, null, 0, 4096);
if ($body === false) { return false; }
if (strpos($body, '# fb-local-guard: v' . FB_LOCAL_GUARD_VER) !== false) { return false; }
return true;
}
/** 本机附件是否可写 */
function fb_local_writable() {
fb_local_ensure_dir();
$dir = fb_local_dir();
return is_dir($dir) && is_writable($dir);
}
/**
* 本机附件目录的诊断信息(供后台「存储体检」显示)。
* 真实踩过的坑:用 SFTP/部署账号手动建 uploads/ 时,目录属主是部署账号,
* PHP 进程(www)写不进去 —— 表现就是「文件已存在但不可写」。
* 此时最省事的办法是删掉该目录,让 PHP 自己重建(属主即 www)。
* @return array{ok:bool,dir:string,tip:string}
*/
function fb_local_diag() {
$dir = fb_local_dir();
fb_local_ensure_dir(); // 顺带补齐 .htaccess / index.html
$exist = is_dir($dir);
if (!$exist) {
return ['ok' => false, 'dir' => $dir,
'tip' => '目录不存在且无法创建:上级目录 feedback/ 对 PHP 进程不可写,请检查目录权限。'];
}
if (!is_writable($dir)) {
$owner = function_exists('posix_getpwuid') ? @posix_getpwuid(@fileowner($dir)) : null;
$oname = is_array($owner) && isset($owner['name']) ? $owner['name'] : ('uid ' . @fileowner($dir));
$me = function_exists('posix_geteuid') ? @posix_getpwuid(@posix_geteuid()) : null;
$mname = is_array($me) && isset($me['name']) ? $me['name'] : ('uid ' . @getmyuid());
return ['ok' => false, 'dir' => $dir,
'tip' => '目录属主是 ' . $oname . ',而 PHP 以 ' . $mname . ' 运行 → 不可写。'
. '处理办法:删掉整个 uploads/ 目录(包括里面的 .htaccess),再刷新本页,'
. 'PHP 会自动重建并把属主设为自己;或在宝塔面板里把 uploads/ 属主改为站点运行用户。'];
}
return ['ok' => true, 'dir' => $dir, 'tip' => ''];
}
/** 当前存储是否可用(决定要不要显示上传入口) */
function fb_store_enabled() {
$m = fb_store_mode();
if ($m === 'local') { return fb_local_writable(); }
if ($m === 'oss') { return fb_oss_enabled(); }
return false;
}
/** 生成「本机存储」的对象键 */
function fb_local_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
return 'local/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/** 该附件是不是存在本机 */
function fb_attach_is_local($key) {
return strpos((string)$key, 'local/') === 0;
}
/* ==================================================================
* 软件图标(管理员上传的图片)
* ------------------------------------------------------------------
* 🔴 存储位置【跟随后台配置】,与附件完全同一套规则:
* attachment.storage = local → local/icons/YYYY/MM/<hex>.<ext> 写 uploads/
* attachment.storage = oss → <oss.prefix>/icons/YYYY/MM/<hex>.<ext> 写私有桶
* 管理员在后台配的哪种,图标就存哪种;前台地址两种模式完全一致,
* 都是 media.php?k=... 代理,换存储方式不会让已有图标失效。
*
* 早期实现把图标键固定写成 `icons/...`(不带 local/ 前缀),
* 结果 fb_attach_is_local() 永远返回 false —— 本机模式下文件明明写进了
* uploads/icons/,media.php 却当成 OSS 对象去读,直接 503。
* 这是「本地存储时图标显示不出来」的根因,键必须带存储前缀。
*
* 为什么不复用普通附件:
* 普通附件必须挂在某条 thread 下才允许被 media.php 读出(否则本站就成了
* 「能读整个桶」的开放代理)。软件图标不属于任何帖子,用它那套鉴权会被拒。
* 于是键里多一段 `icons/`,media.php 据此识别并按公开资源处理(写入仍受控)。
* ================================================================== */
function fb_icon_key_prefix() {
return 'icons/';
}
/**
* 图标允许的扩展名(比附件白名单窄:图标不需要 gif 动图)。
* ⚠️ 刻意不收 svg:SVG 能内嵌 <script>,一旦被当图片直出到浏览器就是 XSS。
* 图标是管理员自己传的,但防线不该依赖「管理员不会传坏东西」。
*
* 定义在这里而不是 admin_taxonomy.php:media.php 与校验函数都会用到,
* 不能依赖后台那个文件被加载(它在 bootstrap 里并不 require)。
*/
function fb_icon_allowed_ext() {
return ['png', 'jpg', 'jpeg', 'webp'];
}
/**
* 这个键是不是软件图标。
* 同时认正常形态(local/icons/… 、<prefix>/icons/…)与历史形态(icons/…)。
* 历史形态必须认,否则用户早先上传、仍存在桶里的图标就成了读不到的孤儿。
*/
function fb_icon_is_key($key) {
return (bool)preg_match('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/#', (string)$key);
}
/**
* 生成一个图标对象键 —— 存储位置由后台配置决定,与附件一致。
* local 模式带 `local/` 前缀,OSS 模式带 oss.prefix 前缀;
* 两种都能被 fb_attach_is_local() / fb_local_path_of() 正确解析。
*/
function fb_icon_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
$leaf = fb_icon_key_prefix() . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
if (fb_store_mode() === 'local') {
return 'local/' . $leaf;
}
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
if ($prefix === '') { $prefix = 'fb'; }
return $prefix . '/' . $leaf;
}
/**
* 图标键是否合法。
* 接受两种形态:
* ① 正常形态:<local|oss.prefix>/icons/YYYY/MM/<16hex>.<ext>
* ② 历史形态:icons/YYYY/MM/<16hex>.<ext>(无存储前缀)
* —— 2026-11-03 之前的代码把键硬编码成这种样子,用户当时上传的图
* 真实存在于桶根下的 icons/ 目录。这些对象是有效资产,
* 不能因为键「格式不规范」就让图标显示不出来,所以这里认它。
* 新写入一律走正常形态,见 fb_icon_make_key()。
*
* 🔴 扩展名必须是白名单里的图片类型,不能只写 `[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('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/\d{4}/\d{2}/[a-f0-9]{16}\.([a-z0-9]{2,5})$#', $key, $m)) {
return false;
}
return in_array(strtolower($m[1]), fb_icon_allowed_ext(), 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, '/');
if (strpos($rel, 'local/') === 0) { $rel = substr($rel, 6); }
$rel = str_replace(['..', "\0"], '', $rel);
return fb_local_dir() . '/' . ltrim($rel, '/');
}
/**
* 附件的【唯一对外地址】—— 一律走本站 media.php 代理。
* ------------------------------------------------------------------
* ★ 无论附件存在本机 uploads/ 还是阿里云 OSS,前台给浏览器的都是这一个形态:
* media.php?k=<对象键>
* 两个目的:
* ① 路径统一 —— 存储方式(local / oss)对页面与前端 JS 完全透明,
* 换存储方式不会产生两套 URL 逻辑、旧的本地附件也不会失效;
* ② 不暴露真实地址 —— 本机模式下不再直接给出 uploads/2026/09/xxx.jpg
* (避免被猜目录、被批量抓取),OSS 模式下更不会出现任何 bucket / endpoint 痕迹。
* 真实的物理位置只由服务器在 media.php 内部按 key 前缀解析。
* ⚠️ 数据库里只存对象键(oss_key),地址是渲染时现算的 ——
* 所以本改动对【历史附件】同样立即生效,无需迁移数据。
*/
function fb_attach_proxy_url($key) {
return fb_base_url() . '/media.php?k=' . rawurlencode(ltrim((string)$key, '/'));
}
/** 附件的访问地址(原图;统一走 media.php 代理,与存储方式无关) */
function fb_attach_url($key) {
return fb_attach_proxy_url($key);
}
/**
* 附件的展示地址(缩略图;同样统一走 media.php 代理)
* · OSS:借 OSS 图片处理在服务端缩放后再转发,省下行流量;
* · 本机:没有图片处理能力,直接给原图(仍是 media.php 代理地址)。
*/
function fb_attach_thumb_url($key, $w = 640, $mime = '') {
if (fb_attach_is_local($key)) { return fb_attach_proxy_url($key); }
return fb_oss_thumb_url($key, $w, $mime);
}
/** 删除附件实体(先按 key 分流,返回是否可继续删库记录) */
function fb_attach_delete($key) {
if (fb_attach_is_local($key)) {
$p = fb_local_path_of($key);
if (!is_file($p)) { return true; }
return @unlink($p);
}
if (!fb_oss_enabled()) { return true; }
return fb_oss_delete($key);
}
/**
* 该类型是否可用。
*
* ⚠️ 原先靠「数量上限是否为 0」判断开关 —— 但 0 现在表示「不限个数」,
* 两者语义冲突(一个说"关掉"、一个说"不限制")。改为看独立开关:
* upload.image_on / upload.video_on,默认都开。
*/
function fb_upload_kind_on($kind) {
if (!fb_store_enabled()) { return false; }
$key = $kind . '_on';
return (bool)fb_upload_cfg($key, true);
}
/** 归一化扩展名 */
function fb_upload_norm_ext($ext) {
return strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
}
/** 扩展名白名单 */
function fb_upload_ext_ok($kind, $ext) {
$list = (array)fb_upload_cfg($kind . '_exts', []);
return in_array(fb_upload_norm_ext($ext), array_map('strtolower', $list), true);
}
/** MIME 白名单 */
/**
* 探测文件的**真实** MIME(不信任前端上报)。
*
* 🔴 为什么必须有:mime 是客户端 POST 上来的字符串,随便写。
* 把 PHP 代码改名成 .png、上报 mime=image/png,就能写进 uploads/。
* 虽然 .htaccess 禁了脚本执行,但那是唯一一道防线 ——
* 换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。
*
* 优先用 fileinfo(读文件头魔数);没装则用 getimagesize 兜底
* (只认图片,视频降级为「放行」并记录,不阻断正常视频)。
*
* @return string 探测到的 mime;无法判断返回空串
*/
function fb_upload_detect_mime($path) {
if (!is_file($path)) { return ''; }
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$m = @finfo_file($fi, $path);
@finfo_close($fi);
if (is_string($m) && $m !== '' && $m !== 'application/octet-stream') {
return strtolower(trim($m));
}
}
}
// 兜底:图片用 getimagesize(读真实尺寸,伪造难度高)
if (function_exists('getimagesize')) {
$info = @getimagesize($path);
if (is_array($info) && !empty($info['mime'])) {
return strtolower(trim((string)$info['mime']));
}
}
return '';
}
function fb_upload_mime_ok($kind, $mime) {
$list = (array)fb_upload_cfg($kind . '_mimes', []);
return in_array(strtolower(trim((string)$mime)), array_map('strtolower', $list), true);
}
/** 按扩展名猜 MIME(HEAD 复核时服务端返回缺失的兜底) */
function fb_upload_mime_of_ext($ext) {
$map = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'webp' => 'image/webp', 'gif' => 'image/gif',
'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'webm' => 'video/webm',
];
$e = fb_upload_norm_ext($ext);
return isset($map[$e]) ? $map[$e] : 'application/octet-stream';
}
/** 截断(不依赖 mbstring 扩展) */
function fb_upload_shorten($s, $len) {
$s = trim((string)$s);
$s = preg_replace('/[\r\n\t]+/', ' ', $s);
if (fb_len($s) <= $len) { return $s; }
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len));
}
/** 对象键是否合法(且位于本站前缀之下) */
function fb_upload_key_ok($key) {
$key = (string)$key;
if (strlen($key) < 8 || strlen($key) > 200) { return false; }
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);
}
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
return strpos($key, $prefix . '/') === 0;
}
/** 键的扩展名 */
function fb_upload_key_ext($key) {
$e = pathinfo((string)$key, PATHINFO_EXTENSION);
return fb_upload_norm_ext($e);
}
/** 当前附件总量(字节) */
function fb_attach_total_bytes(?PDO $pdo = null) {
$pdo = $pdo ?: fb_db();
return (int)$pdo->query('SELECT COALESCE(SUM(size), 0) FROM attachments')->fetchColumn();
}
/**
* 解析客户端提交的附件 JSON(隐藏字段 attachments)
* @return array [bool $ok, array $list, string $err]
*/
function fb_attach_parse($raw) {
$empty = [[], []];
$raw = trim((string)$raw);
if ($raw === '') { return [true, [], '']; }
if (strlen($raw) > 20000) { return [false, [], '附件信息过长。']; }
$arr = json_decode($raw, true);
if (!is_array($arr)) { return [false, [], '附件信息格式不正确。']; }
$list = [];
$counts = ['image' => 0, 'video' => 0];
$seen = [];
foreach ($arr as $it) {
if (!is_array($it)) { return [false, [], '附件信息格式不正确。']; }
$kind = isset($it['kind']) ? strtolower(trim((string)$it['kind'])) : '';
if ($kind !== 'image' && $kind !== 'video') { return [false, [], '不支持的附件类型。']; }
if (!fb_upload_kind_on($kind)) { return [false, [], '该类型附件未开放上传。']; }
$key = isset($it['key']) ? trim((string)$it['key']) : '';
$size = isset($it['size']) ? (int)$it['size'] : 0;
$mime = isset($it['mime']) ? strtolower(trim((string)$it['mime'])) : '';
if (!fb_upload_key_ok($key)) { return [false, [], '附件地址不合法,请重新上传。']; }
if (isset($seen[$key])) { return [false, [], '存在重复的附件,请重新上传。']; }
$ext = fb_upload_key_ext($key);
if (!fb_upload_ext_ok($kind, $ext)) { return [false, [], '附件格式不被允许(' . e($ext) . ')。']; }
$max = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($size <= 0 || ($max > 0 && $size > $max)) {
return [false, [], ($kind === 'image' ? '单张图片' : '视频') . '大小超出限制。'];
}
if ($mime !== '' && !fb_upload_mime_ok($kind, $mime)) {
return [false, [], '附件类型不被允许。'];
}
$counts[$kind]++;
// 0 = 不限个数。注意别写成 `count > max`,那样 max=0 时传一个就超限,
// 等于「填 0 反而一个都不让传」,与用户意图相反。
$maxPerPost = (int)fb_upload_cfg($kind . '_max_per_post', 0);
if ($maxPerPost > 0 && $counts[$kind] > $maxPerPost) {
return [false, [], ($kind === 'image' ? '图片最多上传 ' : '视频最多上传 ')
. $maxPerPost . ' 个。'];
}
$seen[$key] = true;
$list[] = [
'kind' => $kind,
'key' => $key,
'size' => $size,
'mime' => ($mime !== '' ? $mime : fb_upload_mime_of_ext($ext)),
'name' => isset($it['name']) ? fb_upload_shorten($it['name'], 120) : '',
'width' => isset($it['w']) ? max(0, (int)$it['w']) : null,
'height' => isset($it['h']) ? max(0, (int)$it['h']) : null,
];
}
return [true, $list, ''];
}
/**
* 回源复核:确认对象确实存在、大小一致、内容类型在白名单内。
* 复核结果会以服务端返回的 Content-Type / Content-Length 为准写回 $list。
* @return array [bool $ok, array $list, string $err]
*/
function fb_attach_verify(array $list) {
if (!$list) { return [true, $list, '']; }
if (!fb_store_enabled()) { return [false, [], '附件服务未启用。']; }
// 本机存储:直接查磁盘,不联网
if (fb_store_mode() === 'local') {
foreach ($list as $i => $it) {
if (!fb_attach_is_local($it['key'])) {
return [false, [], '附件地址与当前存储方式不符,请重新上传。'];
}
$p = fb_local_path_of($it['key']);
if (!is_file($p)) {
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
}
$len = (int)@filesize($p);
if ($len > 0) {
if (abs($len - (int)$it['size']) > 1024) {
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
}
$list[$i]['size'] = $len;
}
$ct = '';
if (function_exists('finfo_open')) {
$f = @finfo_open(FILEINFO_MIME_TYPE);
if ($f) { $ct = strtolower(trim((string)@finfo_file($f, $p))); fb_finfo_close($f); }
} elseif (function_exists('mime_content_type')) {
$ct = strtolower(trim((string)@mime_content_type($p)));
}
if ($ct !== '' && $ct !== 'application/octet-stream') {
if (!fb_upload_mime_ok($it['kind'], $ct)) {
return [false, [], '附件内容类型不被允许。'];
}
$list[$i]['mime'] = $ct;
}
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
return [false, [], '附件内容类型不被允许。'];
}
}
return [true, $list, ''];
}
if (!fb_oss_enabled()) { return [false, [], '附件服务未启用。']; }
if (!fb_oss_cfg('verify', true)) { return [true, $list, '']; }
foreach ($list as $i => $it) {
list($status, $h) = fb_oss_head($it['key']);
if ($status < 200 || $status >= 300) {
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
}
$len = isset($h['content-length']) ? (int)$h['content-length'] : 0;
if ($len > 0) {
if (abs($len - (int)$it['size']) > 1024) {
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
}
$list[$i]['size'] = $len;
}
if (!empty($h['content-type'])) {
$ct = strtolower(trim((string)explode(';', $h['content-type'])[0]));
// 服务端返回的类型必须可信:不在白名单里就直接拒绝;
// 仅在「缺失 / octet-stream」时才回落到客户端声明的类型。
if ($ct !== '' && $ct !== 'application/octet-stream') {
if (!fb_upload_mime_ok($it['kind'], $ct)) {
return [false, [], '附件内容类型不被允许。'];
}
$list[$i]['mime'] = $ct;
}
}
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
return [false, [], '附件内容类型不被允许。'];
}
}
return [true, $list, ''];
}
/** 总量上限检查 */
function fb_attach_cap_ok(PDO $pdo, array $list) {
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap <= 0) { return true; }
$add = 0;
foreach ($list as $it) { $add += (int)$it['size']; }
return (fb_attach_total_bytes($pdo) + $add) <= $cap;
}
/** 落库(在主题 / 回复写入之后调用) */
function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
if (!$list) { return 0; }
$now = time();
$st = $pdo->prepare(
'INSERT INTO attachments(thread_id, reply_id, kind, oss_key, mime, size, width, height, orig_name, seq, created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)'
);
$n = 0;
foreach ($list as $i => $it) {
$st->execute([
(int)$threadId,
$replyId ? (int)$replyId : null,
$it['kind'], $it['key'], $it['mime'], (int)$it['size'],
$it['width'] ?: null, $it['height'] ?: null,
$it['name'] !== '' ? $it['name'] : null,
$i, $now,
]);
$n++;
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
fb_attach_pending_clear($it['key']);
}
return $n;
}
/* ==================================================================
* 待入库附件登记
* ------------------------------------------------------------------
* 上传成功 → 登记;提交表单入库 → 转正并移除;一直没提交 → 由 cron 清理。
* 只为 media.php 的代理鉴权服务(上传后、入库前的缩略图预览)。
* ================================================================== */
/** 登记一条待入库附件 */
function fb_attach_pending_mark($key, $kind, $mime, $size) {
try {
$pdo = fb_db();
$pdo->prepare(
'INSERT INTO pending_attachments(oss_key, kind, mime, size, ip_hash, created_at)
VALUES(?,?,?,?,?,?)'
)->execute([(string)$key, (string)$kind, (string)$mime, (int)$size, fb_ip_hash(), time()]);
return true;
} catch (Throwable $e) { return false; }
}
/** 转正/放弃后移除登记(幂等) */
function fb_attach_pending_clear($key) {
try {
$pdo = fb_db();
$pdo->prepare('DELETE FROM pending_attachments WHERE oss_key = ?')->execute([(string)$key]);
return true;
} catch (Throwable $e) { return false; }
}
/** 该对象键是否由【当前访客】在有效期内登记过(media.php 鉴权用) */
function fb_attach_pending_mine($key, $ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare(
'SELECT id FROM pending_attachments
WHERE oss_key = ? AND ip_hash = ? AND created_at >= ? LIMIT 1'
);
$st->execute([(string)$key, fb_ip_hash(), time() - (int)$ttl]);
return (bool)$st->fetch();
} catch (Throwable $e) { return false; }
}
/** 清理过期的待入库登记(cron 调用;只删记录,不动 OSS 对象——那属于未提交的孤儿,另行处理) */
function fb_attach_pending_gc($ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare('DELETE FROM pending_attachments WHERE created_at < ?');
$st->execute([time() - (int)$ttl]);
return $st->rowCount();
} catch (Throwable $e) { return 0; }
}
/** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */
function fb_attach_for_thread(PDO $pdo, $threadId) {
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
$st->execute([(int)$threadId]);
$out = ['main' => [], 'replies' => []];
foreach ($st->fetchAll() as $r) {
if (empty($r['reply_id'])) { $out['main'][] = $r; }
else { $out['replies'][(int)$r['reply_id']][] = $r; }
}
return $out;
}
/** 批量取(列表页),返回 [thread_id => [image rows]] */
function fb_attach_map_images(PDO $pdo, array $threadIds, $perThread = 3) {
$ids = [];
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT id, thread_id, oss_key, mime FROM attachments
WHERE kind = 'image' AND thread_id IN ($in) ORDER BY thread_id ASC, seq ASC, id ASC");
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$tid = (int)$r['thread_id'];
if (!isset($out[$tid])) { $out[$tid] = []; }
if (count($out[$tid]) < $perThread) { $out[$tid][] = $r; }
}
return $out;
}
/**
* 渲染附件区($rows 为 attachments 行数组)
* ------------------------------------------------------------------
* 输出带 data-gallery 的容器;同一容器内(=同一个主题 / 同一条回复)的全部附件
* 构成一个「灯箱画廊」,由 assets/app.js 接管:
* · 点击缩略图 → 页内放大(不跳转新标签页,仍是 <a href> 所以禁用 JS 也能看)
* · ‹ › / ← → / 触摸左右滑 → 在同一帖的多个附件间切换
* · 视频以「放大」按钮进入同一序列,方便逐条翻阅
*/
function fb_attach_render(array $rows, array $opt = []) {
if (!$rows) { return ''; }
$compact = !empty($opt['compact']);
$html = '<div class="atts' . ($compact ? ' compact' : '') . '" data-gallery>';
foreach ($rows as $r) {
$kind = (string)$r['kind'];
$key = (string)$r['oss_key'];
$mime = isset($r['mime']) ? (string)$r['mime'] : '';
$full = fb_attach_url($key);
$name = fb_attach_label($r);
$sizeH = fb_size_h($r['size']);
if ($kind === 'video') {
$html .= '<figure class="att att-video">'
. '<video controls preload="metadata" playsinline src="' . e($full) . '"></video>'
. '<figcaption><span class="att-name">' . e($name) . '</span>'
. '<span class="att-size">' . e($sizeH) . '</span>'
. '<button type="button" class="att-zoom" aria-label="放大查看该视频"'
. ' data-lb-kind="video" data-lb-src="' . e($full) . '"'
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">放大</button>'
. '</figcaption>'
. '</figure>';
} else {
// 缩略图(列表/正文)与灯箱显示图分开取尺寸,避免灯箱里加载原图
$thumb = fb_attach_thumb_url($key, $compact ? 360 : 640, $mime);
$view = fb_attach_thumb_url($key, 1600, $mime);
$html .= '<figure class="att att-image">'
. '<a class="att-lb" href="' . e($full) . '" target="_blank" rel="noopener noreferrer"'
. ' data-lb-kind="image" data-lb-src="' . e($view) . '" data-lb-full="' . e($full) . '"'
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">'
. '<img loading="lazy" src="' . e($thumb) . '" alt="' . e($name) . '"></a>'
. '</figure>';
}
}
return $html . '</div>';
}
/** 附件展示名(回退为「图片/视频 + 序号」) */
function fb_attach_label(array $r) {
$n = isset($r['orig_name']) ? trim((string)$r['orig_name']) : '';
if ($n !== '') { return fb_excerpt($n, 40); }
return ((string)$r['kind'] === 'video') ? '视频' : '图片';
}
/** 人类可读大小 */
function fb_size_h($bytes) {
$b = (int)$bytes;
if ($b < 1024) { return $b . ' B'; }
if ($b < 1048576) { return round($b / 1024, 1) . ' KB'; }
return round($b / 1048576, 1) . ' MB';
}
/** 统计某主题附件数(列表页标记用) */
function fb_attach_count_map(PDO $pdo, array $threadIds) {
$ids = [];
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT thread_id, COUNT(*) c FROM attachments WHERE thread_id IN ($in) GROUP BY thread_id");
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) { $out[(int)$r['thread_id']] = (int)$r['c']; }
return $out;
}
/**
* 清理过期附件:先删 OSS 对象,成功后再删库记录(失败则留待下次重试)。
* @return int 已清理条数
*/
function fb_attach_cleanup(PDO $pdo, $days = null, $limit = 200) {
$days = $days === null ? (int)fb_upload_cfg('retention_days', 365) : (int)$days;
if ($days <= 0) { return 0; }
$cut = time() - $days * 86400;
$st = $pdo->prepare('SELECT id, oss_key FROM attachments WHERE created_at < ? ORDER BY created_at ASC LIMIT ' . (int)$limit);
$st->execute([$cut]);
$rows = $st->fetchAll();
$del = $pdo->prepare('DELETE FROM attachments WHERE id = ?');
$n = 0;
foreach ($rows as $r) {
if (fb_attach_delete($r['oss_key'])) {
$del->execute([(int)$r['id']]);
$n++;
}
}
return $n;
}