2026-09-28 20:34:37 +08:00
|
|
|
|
<?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 不会自动回来 —— 必须在这里补写,
|
|
|
|
|
|
* 否则「禁列目录 + 禁执行脚本」的防护就丢了。
|
|
|
|
|
|
*/
|
|
|
|
|
|
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)) {
|
|
|
|
|
|
$rule = "# 本机附件目录(由程序自动生成)\n"
|
|
|
|
|
|
. "# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本\n"
|
|
|
|
|
|
. "Options -Indexes\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";
|
|
|
|
|
|
@file_put_contents($ht, $rule);
|
|
|
|
|
|
@chmod($ht, 0644);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$idx = $dir . '/index.html';
|
|
|
|
|
|
if (!is_file($idx)) {
|
|
|
|
|
|
@file_put_contents($idx, '');
|
|
|
|
|
|
@chmod($idx, 0644);
|
|
|
|
|
|
}
|
|
|
|
|
|
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, '/');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 附件的访问地址(自动按 key 前缀分流) */
|
|
|
|
|
|
function fb_attach_url($key) {
|
|
|
|
|
|
if (fb_attach_is_local($key)) {
|
|
|
|
|
|
return fb_base_url() . '/uploads/' . substr(ltrim((string)$key, '/'), 6);
|
|
|
|
|
|
}
|
|
|
|
|
|
return fb_oss_url($key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 附件的展示地址(缩略图;本机存储没有图片处理,直接给原图) */
|
|
|
|
|
|
function fb_attach_thumb_url($key, $w = 640, $mime = '') {
|
|
|
|
|
|
if (fb_attach_is_local($key)) { return fb_attach_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++;
|
2026-09-28 22:12:28 +08:00
|
|
|
|
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
|
|
|
|
|
|
fb_attach_pending_clear($it['key']);
|
2026-09-28 20:34:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
return $n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 22:12:28 +08:00
|
|
|
|
/* ==================================================================
|
|
|
|
|
|
* 待入库附件登记
|
|
|
|
|
|
* ------------------------------------------------------------------
|
|
|
|
|
|
* 上传成功 → 登记;提交表单入库 → 转正并移除;一直没提交 → 由 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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 20:34:37 +08:00
|
|
|
|
/** 取某个主题的附件:['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;
|
|
|
|
|
|
}
|