Files
FeedLite/lib/attach.php
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

528 lines
22 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 不会自动回来 —— 必须在这里补写,
* 否则「禁列目录 + 禁执行脚本」的防护就丢了。
*/
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++;
}
return $n;
}
/** 取某个主题的附件:['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;
}