Files
FeedLite/lib/attach.php
T
laoyang 5006d54be3 附件读取统一走 media.php 代理,真实地址不再暴露
无论附件存在本机 uploads/ 还是 OSS 私有桶,前台给浏览器的地址现在完全一致:
media.php?k=<对象键>。界面上不再出现 uploads/ 真实路径,也不出现任何 bucket /
endpoint / 签名痕迹。

- lib/attach.php:新增 fb_attach_proxy_url(),fb_attach_url() 与
  fb_attach_thumb_url() 的本机分支一并改走代理(地址不落库、渲染现算,
  对历史附件立即生效,无需迁移)。
- uploads/ 整目录禁止 Web 直读:附件只能经 media.php 出,真实物理路径
  即便被猜到也取不到,同时绕不开 media.php 的可见性鉴权。
- 规则版本化自动升级:.htaccess 内埋 `# fb-local-guard: vN`,
  旧版规则在任意请求时由 PHP(目录属主)整份重写。之所以不能靠部署覆盖:
  部署账号对 uploads/ 无写权限,只有程序自己能改。
- 随包 uploads/.htaccess 改为程序产出的逐字节副本,并加断言防漂移
  (否则线上会被自动升级覆盖,随包那份形同虚设)。
- 顺带修复真实源码泄漏:config.php.bak_<时间戳> 这类「非 .php 结尾、
  但含源码」的备份文件此前可被公开下载。加固根 .htaccess:
  去掉正则结尾锚点、补 .php[.\-_] 兜底、点名拒绝 config.php / oss.sts.php。
- media.php / upload.php / admin.php / README.md:文档与响应头同步。

验证:php -l 19 文件 0 失败;主回归 25/25、附件专项 22/22、安装向导 60/60、
统一代理渲染 7/7、反向验证 V1-V4 全被抓住;线上冒烟 30/30、
线上统一代理真机 13/13(OSS 私有桶 + 内网 endpoint 下上传→代理读回→
Range 206→伪造键 403)。
2026-09-28 23:33:45 +08:00

627 lines
26 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;
}
/** 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 或未配置则视为关闭) */
function fb_upload_kind_on($kind) {
if (!fb_store_enabled()) { return false; }
return (int)fb_upload_cfg($kind . '_max_per_post', 0) > 0;
}
/** 归一化扩展名 */
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 白名单 */
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; }
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]++;
if ($counts[$kind] > (int)fb_upload_cfg($kind . '_max_per_post', 0)) {
return [false, [], ($kind === 'image' ? '图片最多上传 ' : '视频最多上传 ')
. (int)fb_upload_cfg($kind . '_max_per_post', 0) . ' 个。'];
}
$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))); @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;
}