\n Require all denied\n\n" . "\n Order allow,deny\n Deny from all\n\n\n" . "\n" . " \n Require all denied\n \n" . " \n Order allow,deny\n Deny from all\n \n" . "\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 接管: * · 点击缩略图 → 页内放大(不跳转新标签页,仍是 所以禁用 JS 也能看) * · ‹ › / ← → / 触摸左右滑 → 在同一帖的多个附件间切换 * · 视频以「放大」按钮进入同一序列,方便逐条翻阅 */ function fb_attach_render(array $rows, array $opt = []) { if (!$rows) { return ''; } $compact = !empty($opt['compact']); $html = '
'; 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 .= '
' . '' . '
' . e($name) . '' . '' . e($sizeH) . '' . '' . '
' . '
'; } else { // 缩略图(列表/正文)与灯箱显示图分开取尺寸,避免灯箱里加载原图 $thumb = fb_attach_thumb_url($key, $compact ? 360 : 640, $mime); $view = fb_attach_thumb_url($key, 1600, $mime); $html .= '
' . '' . '' . e($name) . '' . '
'; } } return $html . '
'; } /** 附件展示名(回退为「图片/视频 + 序号」) */ 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; }