FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)

- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
This commit is contained in:
2026-09-28 20:34:37 +08:00
commit cede838b5e
42 changed files with 5911 additions and 0 deletions
+527
View File
@@ -0,0 +1,527 @@
<?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;
}
+95
View File
@@ -0,0 +1,95 @@
<?php
/**
* 启动引导:加载配置、注册自动加载、开启会话、初始化数据库。
* 所有页面(index/thread/new/api)都以此为入口。
*/
if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__));
}
/* ------------------------------------------------------------------
* 错误显示策略必须最先设置:
* 任何被打印到页面的 Warning/Deprecated 都会让响应头提前发出,
* 从而令随后的 session_start() 失败(表现为「会话已过期」、CSRF 全部失效)。
* 所以这里在 require 任何库文件之前就把 display_errors 关掉。
* ------------------------------------------------------------------ */
@ini_set('display_errors', '0');
@ini_set('log_errors', '1');
error_reporting(E_ALL);
// 兜底:捕获致命错误时也不要向页面输出堆栈(生产环境)
if (!defined('FB_BOOTSTRAPPED')) {
define('FB_BOOTSTRAPPED', 1);
}
// 后台改设置后自增,用来让 fb_cfg 的进程内缓存失效
if (!isset($GLOBALS['__fb_cfg_stamp'])) {
$GLOBALS['__fb_cfg_stamp'] = 0;
}
if (!function_exists('fb_cfg')) {
/**
* 读取配置(点分路径,如 fb_cfg('smtp.host'))
* 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default
*
* config.php 不参与版本管理(见 .gitignore),新部署时可从 config.example.php 复制一份;
* 若不存在则直接以 config.example.php 为默认值运行,保证 clone 下来即可跑。
*/
function fb_cfg($path = null, $default = null) {
static $merged = null;
static $stamp = -1;
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
if ($merged === null || $stamp !== $now) {
$local = FB_ROOT . '/config.php';
$example = FB_ROOT . '/config.example.php';
if (is_file($local)) {
$cfg = (array)(require $local);
} elseif (is_file($example)) {
$cfg = (array)(require $example);
} else {
$cfg = [];
}
// 后台设置以嵌套数组形式叠加在文件配置之上(只覆盖同名键)
$tree = function_exists('fb_setting_tree') ? fb_setting_tree() : [];
$merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg;
$stamp = $now;
}
if ($path === null || $path === '') { return $merged; }
$cur = $merged;
foreach (explode('.', (string)$path) as $k) {
if (is_array($cur) && array_key_exists($k, $cur)) {
$cur = $cur[$k];
} else {
return $default;
}
}
return $cur;
}
}
require_once FB_ROOT . '/lib/util.php';
require_once FB_ROOT . '/lib/db.php';
require_once FB_ROOT . '/lib/oss.php';
require_once FB_ROOT . '/lib/attach.php';
require_once FB_ROOT . '/lib/captcha.php';
require_once FB_ROOT . '/lib/mailer.php';
require_once FB_ROOT . '/lib/layout.php';
// 会话(CLI 下不启用)
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
$p = session_get_cookie_params();
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
@session_set_cookie_params([
'lifetime' => 0,
'path' => !empty($p['path']) ? $p['path'] : '/',
'domain' => '',
'secure' => $https,
'httponly' => true,
'samesite' => 'Lax',
]);
@session_start();
}
// 初始化数据库(建表)
fb_db();
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* 人机验证:趣味问答
* ------------------------------------------------------------------
* 无需外部服务、无需图片库。每次生成一道随机小题(算术为主,
* 混入日历 / 生活常识题),答案存放在 Session 中,一次性使用。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 静态趣味题库:[题目, 可接受的答案(数组,忽略大小写与标点)] */
function fb_captcha_bank() {
return [
['一年有几个月?', ['12', '十二']],
['闰年的二月有多少天?', ['29', '二十九']],
['一星期有几天?', ['7', '七']],
['一年有几个季节?', ['4', '四']],
['一个季度有几个月?', ['3', '三']],
['时针从 3 点走到 9 点,一共走了几个小时?', ['6', '六']],
['钟表从 12 点顺时针走 3 个小时,是几点?', ['3', '三点', '3点']],
['把 「789」 倒过来念,是多少?', ['987']],
['「轻反馈」这几个字,一共几个字?', ['3', '三']],
['汉字「五」表示的数字是几?', ['5', '五']],
['2、4、6、8、10 之后的下一个数是多少?', ['12', '十二']],
['从 10 倒着数,10 之后是几?', ['9', '九']],
['「日月」两个字里,有几个「日」字?', ['1', '一']],
['把 3 个 6 相加,结果是多少?', ['18', '十八']],
['100 减去 100 等于多少?', ['0', '零']],
['星期三之后是星期几?', ['4', '四', '星期四', '周四']],
['把 4 个苹果平均分给 2 个人,每人几个?', ['2', '二', '两']],
['「日历」这个词的第一个字是什么?', ['日']],
];
}
/** 生成一道动态算术题 */
function fb_captcha_arith() {
$op = random_int(1, 4);
if ($op === 1) {
$a = random_int(3, 30); $b = random_int(2, 30);
return ['题目: ' . $a . ' 加 ' . $b . ' 等于多少?', [(string)($a + $b)]];
}
if ($op === 2) {
$a = random_int(10, 40); $b = random_int(1, 9);
if ($b > $a) { $t = $a; $a = $b; $b = $t; }
return ['题目: ' . $a . ' 减 ' . $b . ' 等于多少?', [(string)($a - $b)]];
}
if ($op === 3) {
$a = random_int(2, 9); $b = random_int(2, 9);
return ['题目: ' . $a . ' 乘以 ' . $b . ' 等于多少?', [(string)($a * $b)]];
}
// 先乘后加
$a = random_int(1, 5); $b = random_int(2, 6); $c = random_int(2, 6);
return ['题目: ' . $a . ' 加 ' . $b . ' 乘 ' . $c . ' 等于多少?(先乘后加)', [(string)($a + $b * $c)]];
}
/** 归一化答案:全角转半角、去空白与常见标点、转小写 */
function fb_captcha_norm($s) {
$s = (string)$s;
if (function_exists('mb_convert_kana')) {
$s = mb_convert_kana($s, 'as', 'UTF-8'); // 全角字母/数字/空格 -> 半角
}
$s = str_replace(
['?', '?', '。', '.', '.', ',', ',', '!', '!', '、', ':', ':', ';', ';', '"', "'", '“', '”', '‘', '’', ' ', ' ', "\r", "\n", "\t"],
'',
$s
);
if (function_exists('mb_strtolower')) {
$s = mb_strtolower($s, 'UTF-8');
} else {
$s = strtolower($s);
}
return $s;
}
/** 生成新的验证题,写入 Session,返回题面文本 */
function fb_captcha_new() {
if (random_int(1, 100) <= 65) {
list($q, $answers) = fb_captcha_arith();
} else {
$bank = fb_captcha_bank();
list($q, $answers) = $bank[array_rand($bank)];
}
$_SESSION['fb_cap'] = [
'q' => $q,
'answers'=> $answers,
'ts' => time(),
'tries' => 0,
];
return $q;
}
/** 取当前题目(不存在则新建) */
function fb_captcha_question() {
if (empty($_SESSION['fb_cap']['q']) || !empty($_SESSION['fb_cap']['used'])) {
return fb_captcha_new();
}
// 超过 30 分钟重新出题
if (time() - (int)$_SESSION['fb_cap']['ts'] > 1800) {
return fb_captcha_new();
}
return (string)$_SESSION['fb_cap']['q'];
}
/** 校验用户答案;一次性使用,答错累计,超过 5 次作废需换题 */
function fb_captcha_check($input) {
if (empty($_SESSION['fb_cap']['answers'])) { return false; }
$cap = $_SESSION['fb_cap'];
if (time() - (int)$cap['ts'] > 1800) {
unset($_SESSION['fb_cap']);
return false;
}
$n = fb_captcha_norm($input);
$ok = false;
if ($n !== '') {
foreach ((array)$cap['answers'] as $a) {
$an = fb_captcha_norm($a);
if ($an === '') { continue; }
if ($n === $an) { $ok = true; break; }
if (is_numeric($n) && is_numeric($an) && (float)$n === (float)$an) { $ok = true; break; }
}
}
$_SESSION['fb_cap']['tries'] = (int)$cap['tries'] + 1;
if ($ok) {
unset($_SESSION['fb_cap']); // 用后即焚
return true;
}
if ($_SESSION['fb_cap']['tries'] >= 5) {
unset($_SESSION['fb_cap']); // 作废,下次自动换题
}
return false;
}
+178
View File
@@ -0,0 +1,178 @@
<?php
/**
* SQLite 数据库初始化与迁移
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 打开(或创建)数据库连接,单例 */
function fb_db_init($cfg) {
static $pdo = null;
if ($pdo instanceof PDO) { return $pdo; }
$path = (string)$cfg['db_path'];
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$fresh = !file_exists($path);
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
fb_db_migrate($pdo);
if ($fresh) { @chmod($path, 0640); }
return $pdo;
}
/** 便捷访问器 */
function fb_db() {
return fb_db_init(fb_cfg());
}
/** 建表(幂等) */
function fb_db_migrate(PDO $pdo) {
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
author_phone TEXT,
status TEXT NOT NULL DEFAULT 'open',
reply_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ip_hash TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
body TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
ip_hash TEXT,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_replies_thread ON replies(thread_id, created_at)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS notify_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
reply_id INTEGER NOT NULL,
to_email TEXT NOT NULL,
created_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
tries INTEGER NOT NULL DEFAULT 0,
err TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_notify_thread ON notify_log(thread_id, to_email)');
// 兼容早期建库:确保 notify_log.tries 存在
$cols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
$has = false;
foreach ($cols as $c) { if (isset($c['name']) && $c['name'] === 'tries') { $has = true; break; } }
if (!$has) { $pdo->exec('ALTER TABLE notify_log ADD COLUMN tries INTEGER NOT NULL DEFAULT 0'); }
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
reply_id INTEGER,
kind TEXT NOT NULL DEFAULT 'image',
oss_key TEXT NOT NULL,
mime TEXT,
size INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
orig_name TEXT,
seq INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE,
FOREIGN KEY(reply_id) REFERENCES replies(id) ON DELETE CASCADE
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_thread ON attachments(thread_id, seq)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_reply ON attachments(reply_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS rate_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
action TEXT NOT NULL,
created_at INTEGER NOT NULL
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_rl ON rate_limit(ip_hash, action, created_at)');
/* ---- 站点设置(后台可改,覆盖 config.php 的同名项)----
* 只存「后台允许修改」的键,见 util.php 的 fb_setting_keys()。
* value 存 JSON,读取时自动还原类型。
*/
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at INTEGER
);
SQL
);
/* ---- 管理员操作日志 ---- */
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS mod_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER,
action TEXT NOT NULL,
detail TEXT,
created_at INTEGER NOT NULL,
ip_hash TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_thread ON mod_log(thread_id, created_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_time ON mod_log(created_at DESC)');
// ---- 审核(先审后显)----
// visible:0=待审核/未公开,1=审核通过已公开
// mod_note:审核说明(关闭时写给提问人的解释,会随邮件发出)
// moderated_at:审核时间
$tcols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
$tnames = [];
foreach ($tcols as $c) { if (isset($c['name'])) { $tnames[(string)$c['name']] = true; } }
if (!isset($tnames['visible'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN visible INTEGER NOT NULL DEFAULT 0');
// 功能上线前已存在的内容视为"已审核通过",保持原来的可见性
$pdo->exec('UPDATE threads SET visible = 1');
}
if (!isset($tnames['mod_note'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN mod_note TEXT');
}
if (!isset($tnames['moderated_at'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN moderated_at INTEGER');
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads(visible, created_at DESC)');
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* 页面外壳(头部 / 底部)
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/**
* 输出页面头部
* @param string $title 页面标题
* @param array $opt ['robots' => 'noindex', 'desc' => '...']
*/
function fb_layout_head($title, array $opt = []) {
$site = (string)fb_cfg('site_name');
$robots = isset($opt['robots']) ? (string)$opt['robots'] : 'index,follow';
$full = $title === '' ? $site : ($title . ' · ' . $site);
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0057B8">
<meta name="robots" content="<?php echo e($robots); ?>">
<title><?php echo e($full); ?></title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="assets/style.css?v=6">
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
<span class="brand-text"><?php echo e($site); ?></span>
</a>
<div class="spacer"></div>
<a class="navlink hide-sm" href="./">全部问题</a>
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?>
<a class="btn-mini" href="new.php">我要反馈</a>
</div>
</header>
<main class="wrap">
<?php
}
/** 输出页面底部 */
function fb_layout_foot() {
?><footer class="foot">
<div class="wrap">
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
<p class="muted"><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 FeedLite 驱动</p>
</div>
</footer>
<script src="assets/app.js?v=5"></script>
</body>
</html><?php
}
/** 渲染提示条 */
function fb_flash_box($msg, $type = 'error') {
if ($msg === '' || $msg === null) { return; }
echo '<div class="flash ' . e($type) . '">' . e($msg) . '</div>';
}
/**
* 渲染附件上传控件(图片 / 视频,浏览器直传 OSS)
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。
*
* @param array $existing 校验失败时已上传的附件描述(避免用户重复上传)
*/
function fb_uploaders(array $existing = []) {
$kinds = [];
if (fb_upload_kind_on('image')) { $kinds[] = 'image'; }
if (fb_upload_kind_on('video')) { $kinds[] = 'video'; }
if (!$kinds) { return; }
$init = [];
foreach ($existing as $it) {
$kind = isset($it['kind']) ? (string)$it['kind'] : 'image';
$key = isset($it['key']) ? (string)$it['key'] : '';
if ($key === '') { continue; }
$init[] = [
'kind' => $kind,
'key' => $key,
'size' => isset($it['size']) ? (int)$it['size'] : 0,
'name' => isset($it['name']) ? (string)$it['name'] : '',
'mime' => isset($it['mime']) ? (string)$it['mime'] : '',
'w' => isset($it['width']) ? (int)$it['width'] : 0,
'h' => isset($it['height']) ? (int)$it['height'] : 0,
'url' => fb_attach_url($key),
'thumb' => $kind === 'image' ? fb_attach_thumb_url($key, 360) : '',
];
}
?>
<div class="uploader-box"
data-uploader-root
data-csrf="<?php echo e(fb_csrf_token()); ?>"
data-sign-url="upload.php"
data-init="<?php echo e(json_encode($init, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>">
<?php foreach ($kinds as $kind):
$isImg = ($kind === 'image');
$maxN = (int)fb_upload_cfg($kind . '_max_per_post', 1);
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
$accept = implode(',', (array)fb_upload_cfg($kind . '_mimes', []));
$label = $isImg ? '图片' : '视频';
$desc = $isImg
? ('最多 ' . $maxN . ' 张,单张不超过 ' . fb_size_h($maxB) . ';大图会自动压缩后上传')
: ('最多 ' . $maxN . ' 个,单个不超过 ' . fb_size_h($maxB));
?>
<div class="uploader" data-uploader data-kind="<?php echo e($kind); ?>"
data-max-count="<?php echo $maxN; ?>"
data-max-bytes="<?php echo $maxB; ?>"
data-max-side="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>"
data-accept="<?php echo e($accept); ?>"
data-label="<?php echo e($label); ?>">
<div class="up-head">
<span class="up-title"><?php echo e($label); ?> <i class="hint"><?php echo e($desc); ?></i></span>
<button type="button" class="btn up-pick">选择<?php echo e($label); ?></button>
<input type="file" class="up-file" accept="<?php echo e($accept); ?>"
<?php echo $isImg ? 'multiple' : ''; ?> tabindex="-1">
</div>
<div class="up-list"></div>
</div>
<?php endforeach; ?>
<input type="hidden" name="attachments" class="att-field" value="">
</div>
<?php
}
+256
View File
@@ -0,0 +1,256 @@
<?php
/**
* 极简 SMTP 客户端(无第三方依赖)+ 回复通知逻辑
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 读取一行 SMTP 响应(支持多行 250-... ) */
function fb_smtp_read($fp) {
$data = '';
while (($line = fgets($fp, 1024)) !== false) {
$data .= $line;
if (strlen($line) >= 4 && $line[3] === ' ') { break; }
}
return $data;
}
/** 发送命令并校验响应码 */
function fb_smtp_cmd($fp, $cmd, $codes, &$err, $hideLog = false) {
if ($cmd !== null && $cmd !== '') {
fwrite($fp, $cmd . "\r\n");
}
$resp = fb_smtp_read($fp);
$code = (int)substr(ltrim($resp), 0, 3);
$want = is_array($codes) ? $codes : [(int)$codes];
if (!in_array($code, $want, true)) {
$err = ($hideLog ? '(命令内容已隐藏) ' : '') . trim($resp);
return false;
}
return true;
}
/** 邮件头编码(UTF-8 -> =?UTF-8?B?...?=) */
function fb_mail_header($s) {
return '=?UTF-8?B?' . base64_encode((string)$s) . '?=';
}
/**
* 发送一封 HTML 邮件
* @return array [bool ok, string err]
*/
function fb_smtp_send(array $s, $to, $subject, $html) {
$err = '';
$host = (string)$s['host'];
$port = (int)$s['port'];
$secure = strtolower(isset($s['secure']) ? (string)$s['secure'] : '');
$timeout= (int)(isset($s['timeout']) ? $s['timeout'] : 15);
$verify = !isset($s['verify_ssl']) || $s['verify_ssl'];
if ($host === '' || $port <= 0) { return [false, 'SMTP 参数不完整']; }
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { return [false, '收件地址非法']; }
$sslOpt = $verify
? ['verify_peer' => true, 'verify_peer_name' => true]
: ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true];
$ctx = stream_context_create(['ssl' => $sslOpt]);
$remote = (($secure === 'ssl' || $secure === 'smtps') ? 'ssl://' : '') . $host . ':' . $port;
$fp = @stream_socket_client($remote, $eno, $estr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
if (!$fp) { return [false, '连接失败: ' . $estr . ' (' . $eno . ')']; }
stream_set_timeout($fp, $timeout);
$hello = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
$hello = preg_replace('/[^A-Za-z0-9\.\-]/', '', $hello);
if ($hello === '') { $hello = 'localhost'; }
if (!fb_smtp_cmd($fp, null, 220, $err)) { fclose($fp); return [false, '服务器问候失败: ' . $err]; }
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO 失败: ' . $err]; }
if ($secure === 'tls' || $secure === 'starttls') {
if (!fb_smtp_cmd($fp, 'STARTTLS', 220, $err)) { fclose($fp); return [false, 'STARTTLS 失败: ' . $err]; }
if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp); return [false, 'TLS 握手失败'];
}
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO(TLS) 失败: ' . $err]; }
}
$user = (string)(isset($s['user']) ? $s['user'] : '');
if ($user !== '') {
if (!fb_smtp_cmd($fp, 'AUTH LOGIN', 334, $err)) { fclose($fp); return [false, 'AUTH LOGIN 不受支持: ' . $err]; }
if (!fb_smtp_cmd($fp, base64_encode($user), 334, $err, true)) { fclose($fp); return [false, '用户名未被接受: ' . $err]; }
if (!fb_smtp_cmd($fp, base64_encode((string)$s['pass']), 235, $err, true)) { fclose($fp); return [false, '认证失败(账号 / 密码 / 授权码): ' . $err]; }
}
$from = !empty($s['from']) ? (string)$s['from'] : $user;
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { fclose($fp); return [false, '发件地址非法']; }
if (!fb_smtp_cmd($fp, 'MAIL FROM:<' . $from . '>', 250, $err)) { fclose($fp); return [false, 'MAIL FROM 失败: ' . $err]; }
if (!fb_smtp_cmd($fp, 'RCPT TO:<' . $to . '>', [250, 251], $err)) { fclose($fp); return [false, 'RCPT TO 失败: ' . $err]; }
if (!fb_smtp_cmd($fp, 'DATA', 354, $err)) { fclose($fp); return [false, 'DATA 失败: ' . $err]; }
$fromName = isset($s['from_name']) ? (string)$s['from_name'] : '';
$headers = [];
$headers[] = 'Date: ' . date('r');
$headers[] = 'From: ' . ($fromName !== '' ? fb_mail_header($fromName) . ' ' : '') . '<' . $from . '>';
$headers[] = 'To: <' . $to . '>';
$headers[] = 'Subject: ' . fb_mail_header($subject);
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'Content-Type: text/html; charset=UTF-8';
$headers[] = 'Content-Transfer-Encoding: base64';
$headers[] = 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $hello . '>';
$headers[] = 'X-Mailer: FeedLite';
// 正文用 base64,天然不含以 "." 开头的行,因此无需 dot-stuffing
$body = chunk_split(base64_encode($html), 76, "\r\n");
$data = rtrim(implode("\r\n", $headers) . "\r\n\r\n" . $body, "\r\n");
if (fwrite($fp, $data . "\r\n.\r\n") === false) { fclose($fp); return [false, '写入邮件正文失败']; }
if (!fb_smtp_cmd($fp, null, 250, $err)) { fclose($fp); return [false, '投递失败: ' . $err]; }
@fb_smtp_cmd($fp, 'QUIT', 221, $e2);
fclose($fp);
return [true, ''];
}
/** 渲染通知邮件正文 */
function fb_notify_html($thread, $reply, $isAdmin) {
$base = fb_base_url();
$url = $base . '/thread.php?id=' . (int)$thread['id'];
$site = (string)fb_cfg('site_name');
$name = (string)$thread['author_name'];
$title = (string)$thread['title'];
$rname = $isAdmin ? '官方' : (string)$reply['author_name'];
$rbody = nl2br(htmlspecialchars((string)$reply['body'], ENT_QUOTES, 'UTF-8'));
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
. '<p>' . $safeName . ',你好:</p>'
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
. $rbody
. '</blockquote>'
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#0a6cf5;color:#fff;text-decoration:none;padding:9px 18px;border-radius:8px">查看并回复</a></p>'
. '<p style="color:#6b7280;font-size:13px">若按钮无法点击,请复制链接到浏览器打开:<br>' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '</p>'
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
. '<p style="color:#9aa2ad;font-size:12px">此邮件由系统自动发送,请勿直接回复。</p>'
. '</div>';
}
/**
* 有人回复后,给发帖人发送通知邮件
* @return string 空串=未发送/已发送成功;非空=失败原因
*/
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
$cfg = fb_cfg();
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : [];
if (empty($nt['enabled'])) { return ''; }
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; }
$to = trim((string)$thread['author_email']);
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
// 自己回复自己:不发
if (!$isAdmin && !empty($reply['author_email'])
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
return '';
}
// 节流:同一帖同一收件人 N 秒内不重复发
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
if ($cd > 0) {
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
$st->execute([(int)$thread['id'], $to]);
$last = $st->fetchColumn();
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
}
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
if (empty($smtp['enabled'])) {
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
return 'SMTP 未启用';
}
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
$html = fb_notify_html($thread, $reply, $isAdmin);
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
return $ok ? '' : $err;
}
/** 记录通知尝试 */
function fb_notify_log(PDO $pdo, array $thread, array $reply, $to, $ok, $err) {
try {
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
$st->execute([(int)$thread['id'], (int)$reply['id'], $to, time(), $ok ? 1 : 0, $err]);
} catch (Exception $ex) {
// 静默
}
}
/* ---------------- 关闭通知(判定"不是问题",写给提问人的说明邮件) ---------------- */
/** 关闭通知的邮件正文 */
function fb_notify_closed_html(array $thread, string $note) {
$site = (string)fb_cfg('site_name');
$name = htmlspecialchars((string)$thread['author_name'], ENT_QUOTES, 'UTF-8');
$title = htmlspecialchars((string)$thread['title'], ENT_QUOTES, 'UTF-8');
$body = nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES, 'UTF-8'));
$noteH = nl2br(htmlspecialchars($note, ENT_QUOTES, 'UTF-8'));
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
. '<p>' . $name . ',你好:</p>'
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
. $noteH
. '</div>'
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>'
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
. $body
. '</div>'
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
. '</div>';
}
/**
* 关闭问题(判定"不是问题")时,把说明邮件发给提问人
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
*/
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
$cfg = fb_cfg();
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
if (empty($sm['enabled'])) {
// 仅记录,不算失败
try {
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']);
} catch (Exception $ex) { /* 静默 */ }
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)'];
}
$to = (string)$thread['author_email'];
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
return [false, '提问人邮箱不可用,未发送'];
}
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》');
$subject = sprintf($tpl, (string)$thread['title']);
$html = fb_notify_closed_html($thread, $note);
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html);
try {
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]);
} catch (Exception $ex) { /* 静默 */ }
return [$ok, $ok ? '' : $err];
}
+492
View File
@@ -0,0 +1,492 @@
<?php
/**
* 阿里云 OSS —— 浏览器直传支持(零依赖,只用 PHP 内置函数)
* ------------------------------------------------------------------
* 只用 HMAC-SHA1 签名(PostObject 策略签名 / V1 签名 URL),
* 不依赖官方 SDK,也不依赖 ext-curl(有 curl 时自动优先使用)。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 读取 oss 配置 */
function fb_oss_cfg($k = null, $d = null) {
$c = (array)fb_cfg('oss', []);
if ($k === null) { return $c; }
return array_key_exists($k, $c) ? $c[$k] : $d;
}
/**
* 是否已正确配置(未配置则整个上传功能自动隐藏)
* 两种鉴权方式:
* instance_role —— ECS 实例 RAM 角色(推荐,服务器上不存长期密钥)
* static —— 静态 AccessKey(非 ECS 环境 / 自建机用这个)
*/
function fb_oss_enabled() {
$c = fb_oss_cfg();
if (empty($c['enabled'])) { return false; }
foreach (['bucket', 'endpoint'] as $k) {
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
}
if (fb_oss_credential_source() === 'static') {
foreach (['access_key', 'secret_key'] as $k) {
if (trim((string)(isset($c[$k]) ? $c[$k] : '')) === '') { return false; }
}
return true;
}
return trim((string)fb_oss_cfg('instance_role', '')) !== '';
}
/** 当前鉴权方式:instance_role | static */
function fb_oss_credential_source() {
$s = strtolower(trim((string)fb_oss_cfg('credential_source', 'instance_role')));
return $s === 'static' ? 'static' : 'instance_role';
}
/* ==================================================================
* 临时凭证(ECS 实例 RAM 角色)
* ------------------------------------------------------------------
* 服务器上**不保存**任何长期 AccessKey:
* 给 ECS 实例绑定一个 RAM 角色后,实例内可以通过内网元数据服务
* (http://100.100.100.200,链路本地地址、不出公网)读到一份
* 「有时效、会自动周期轮换」的临时凭证:
* AccessKeyId / AccessKeySecret / SecurityToken / Expiration
* 本程序只负责「把它取回来、缓存起来、拿去签名」。
*
* 对外统一成 ['ak','sk','token','expire'],下游签名逻辑无需关心来源。
* ================================================================== */
/** 临时凭证的本地缓存文件(含 <?php exit; ?> 头,即使站点配置失误也无法被下载) */
function fb_oss_cache_file() {
$p = trim((string)fb_oss_cfg('credential_cache', ''));
if ($p !== '') { return $p; }
$db = (string)fb_cfg('db_path', '');
$dir = $db !== '' ? dirname($db) : sys_get_temp_dir();
return rtrim(str_replace('\\', '/', $dir), '/') . '/oss.sts.php';
}
/** 解析凭证过期时间(ISO8601)→ unix 时间戳 */
function fb_oss_parse_expire($s, $fallbackTtl = 900) {
$s = trim((string)$s);
if ($s !== '') {
$t = strtotime($s);
if ($t !== false && $t > 0) { return (int)$t; }
}
return time() + max(60, (int)$fallbackTtl);
}
function fb_oss_cache_read() {
$f = fb_oss_cache_file();
if (!is_file($f)) { return null; }
$raw = (string)@file_get_contents($f);
$p = strpos($raw, "\n");
if ($p === false) { return null; }
$j = json_decode(substr($raw, $p + 1), true);
if (!is_array($j) || empty($j['ak']) || empty($j['sk'])) { return null; }
return $j;
}
function fb_oss_cache_write(array $c) {
$f = fb_oss_cache_file();
$dir = dirname($f);
if (!is_dir($dir)) { @mkdir($dir, 0777, true); }
$tmp = $f . '.' . bin2hex(random_bytes(4)) . '.tmp';
$body = "<?php exit; ?>\n" . json_encode($c, JSON_UNESCAPED_SLASHES);
if (@file_put_contents($tmp, $body) === false) { return false; }
@chmod($tmp, 0600);
if (!@rename($tmp, $f)) { @unlink($tmp); return false; }
@chmod($f, 0600);
return true;
}
function fb_oss_cache_clear() {
$f = fb_oss_cache_file();
if (is_file($f)) { @unlink($f); }
return true;
}
/** ECS 元数据服务基地址(100.100.100.200 是链路本地地址,不走外网) */
function fb_oss_metadata_base() {
$u = trim((string)fb_oss_cfg('metadata_url', ''));
if ($u === '') { $u = 'http://100.100.100.200'; }
return rtrim($u, '/');
}
/** 加固模式:先取一次性 token(普通模式返回空串) */
function fb_oss_metadata_token() {
if (!fb_oss_cfg('metadata_v2', false)) { return ''; }
$ttl = (int)fb_oss_cfg('metadata_ttl', 600);
$ttl = max(60, min(21600, $ttl));
list($st, , $body) = fb_http('PUT', fb_oss_metadata_base() . '/latest/api/token', [
'timeout' => 3,
'noproxy' => true,
'headers' => ['X-aliyun-ecs-metadata-token-ttl-seconds: ' . $ttl],
]);
if ($st < 200 || $st >= 300) { return ''; }
return trim((string)$body);
}
/** 从 ECS 元数据读取实例 RAM 角色凭证 @return array [bool, array|string] */
function fb_oss_instance_creds() {
$role = trim((string)fb_oss_cfg('instance_role', ''));
if ($role === '') { return [false, '未配置 oss.instance_role']; }
$url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/' . rawurlencode($role);
$headers = ['Accept: application/json'];
$tok = fb_oss_metadata_token();
if ($tok !== '') { $headers[] = 'X-aliyun-ecs-metadata-token: ' . $tok; }
list($st, , $body) = fb_http('GET', $url, ['timeout' => 3, 'noproxy' => true, 'headers' => $headers]);
if ($st < 200 || $st >= 300) {
return [false, '读取实例 RAM 角色失败(HTTP ' . $st . ')'];
}
$j = json_decode((string)$body, true);
if (!is_array($j)) { return [false, '实例元数据返回格式异常']; }
if (isset($j['Code']) && strcasecmp((string)$j['Code'], 'Success') !== 0) {
return [false, '实例元数据返回 ' . (string)$j['Code']];
}
$ak = trim((string)(isset($j['AccessKeyId']) ? $j['AccessKeyId'] : ''));
$sk = trim((string)(isset($j['AccessKeySecret']) ? $j['AccessKeySecret'] : ''));
$tk = trim((string)(isset($j['SecurityToken']) ? $j['SecurityToken'] : ''));
if ($ak === '' || $sk === '') { return [false, '实例 RAM 角色凭证不完整']; }
return [true, [
'ak' => $ak,
'sk' => $sk,
'token' => $tk,
'expire' => fb_oss_parse_expire(isset($j['Expiration']) ? $j['Expiration'] : '', 900),
]];
}
/**
* 当前生效的凭证(进程内记忆 + 文件缓存)。
* 缓存提前 credential_refresh_margin 秒失效,避免恰好在过期瞬间用旧凭证。
*/
function fb_oss_credentials($force = false) {
static $memo = null;
if (!$force && is_array($memo)) { return $memo; }
$margin = max(30, (int)fb_oss_cfg('credential_refresh_margin', 300));
if (!$force) {
$c = fb_oss_cache_read();
if (is_array($c) && !empty($c['expire']) && ((int)$c['expire'] - $margin) > time()) {
$memo = $c;
return $memo;
}
}
// 静态 AccessKey:直接就是长期凭证,不需要缓存与刷新
if (fb_oss_credential_source() === 'static') {
$memo = [
'ak' => trim((string)fb_oss_cfg('access_key', '')),
'sk' => trim((string)fb_oss_cfg('secret_key', '')),
'token' => '',
'expire' => time() + 86400,
'fetched' => time(),
];
if ($memo['ak'] === '' || $memo['sk'] === '') {
$memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => '未配置 oss.access_key / oss.secret_key'];
}
return $memo;
}
list($ok, $r) = fb_oss_instance_creds();
if (!$ok) {
// 读不到就退回空凭证(调用方需自己判断 ok),绝不返回"半截"凭证
$memo = ['ak' => '', 'sk' => '', 'token' => '', 'expire' => 0, 'error' => $r];
return $memo;
}
$r['fetched'] = time();
fb_oss_cache_write($r);
$memo = $r;
return $memo;
}
/**
* 诊断信息(不含密钥,可安全输出到日志/页面)
* ⚠️ 这里【不能】传 $force=true:强制刷新会覆盖 fb_oss_credentials() 的进程内记忆,
* 使同一次请求里「先做体检、再签名」两步拿到不同批次的临时凭证 → SignatureDoesNotMatch。
* 非强制调用会自动优先命中进程内记忆 / 文件缓存,顺带也省掉一次元数据请求。
*/
function fb_oss_cred_status() {
$c = fb_oss_credentials();
$out = [
'label' => fb_oss_credential_source() === 'static' ? '静态 AccessKey' : 'ECS 实例 RAM 角色',
'source' => fb_oss_credential_source(),
'ak_tail' => $c['ak'] !== '' ? substr((string)$c['ak'], -6) : '',
'has_token'=> !empty($c['token']),
'expire' => (int)(isset($c['expire']) ? $c['expire'] : 0),
'ok' => ($c['ak'] !== '' && $c['sk'] !== ''),
'error' => isset($c['error']) ? (string)$c['error'] : '',
];
return $out;
}
/** 当前 AccessKeyId */
function fb_oss_ak() { $c = fb_oss_credentials(); return (string)$c['ak']; }
/** 当前 AccessKeySecret(仅服务器内部使用,绝不返回浏览器) */
function fb_oss_sk() { $c = fb_oss_credentials(); return (string)$c['sk']; }
/** 当前安全令牌(使用临时凭证时必不为空) */
function fb_oss_token() { $c = fb_oss_credentials(); return (string)$c['token']; }
/** 上传入口 host(含协议),形如 https://bucket.oss-cn-beijing.aliyuncs.com */
function fb_oss_host() {
$o = trim((string)fb_oss_cfg('host_override', ''));
if ($o !== '') { return rtrim($o, '/'); }
$c = fb_oss_cfg();
$scheme = (strtolower((string)fb_oss_cfg('url_scheme', 'https')) === 'http') ? 'http' : 'https';
$ep = preg_replace('#^https?://#i', '', trim((string)$c['endpoint']));
return $scheme . '://' . trim((string)$c['bucket']) . '.' . $ep;
}
/** 对外的访问基地址(配置了自定义域名就用它) */
function fb_oss_public_base() {
$d = trim((string)fb_oss_cfg('domain', ''));
if ($d !== '') {
if (!preg_match('#^https?://#i', $d)) { $d = 'https://' . $d; }
return rtrim($d, '/');
}
return fb_oss_host();
}
/** 生成对象键:fb/2026/09/1a2b3c4d5e6f7788.jpg */
function fb_oss_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
$prefix = trim((string)fb_oss_cfg('prefix', 'fb'), '/');
return $prefix . '/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/**
* 计算 PostObject 策略签名(独立函数,便于单元测试交叉验证)
* @param string $policyB64 base64(policy JSON)
* @return string base64(HMAC-SHA1(secret, policyB64))
*/
function fb_oss_sign_policy_string($policyB64, $secret) {
return base64_encode(hash_hmac('sha1', (string)$policyB64, (string)$secret, true));
}
/** 拼一个对象的访问 URL */
function fb_oss_object_url($key, $forcePublicBase = false) {
return ($forcePublicBase ? fb_oss_host() : fb_oss_public_base()) . '/' . ltrim((string)$key, '/');
}
/**
* 生成 OSS V1 签名参数(用于私有 Bucket 的临时访问、HEAD 校验、删除、签名直传)
* StringToSign = METHOD "\n" Content-MD5 "\n" Content-Type "\n" Expires "\n" Resource
* 其中 Resource = /bucket/key,若使用临时凭证则再拼上 "?security-token=<token>"。
* @return array ['OSSAccessKeyId'=>..,'Expires'=>..,'Signature'=>..]
*
* ⚠️ 两个都踩过的坑,改这里之前先读完:
* ① 【必须一次性取出整份凭证】。曾写成 fb_oss_ak()/fb_oss_sk()/fb_oss_token()
* 各调一次 fb_oss_credentials():只要期间发生凭证刷新(或缓存到期换新),
* 签名用的 SK 与随请求发出的 security-token 就来自不同批次 → SignatureDoesNotMatch。
* ② 【security-token 必须算进 Resource】。它是以 URL 查询参数(子资源)形式出现的,
* OSS 计算签名时会把它连同 Resource 一起算。若照搬"官方说 token 不参与签名"而只把它
* 挂在 URL 上,PUT/HEAD/DELETE 这类用签名 URL 的请求会全部 403 SignatureDoesNotMatch
* (PostObject 表单直传不受影响,所以只有"浏览器直传能成功、删不掉"这种诡异现象)。
*/
function fb_oss_sign_v1($method, $key, $expires, $contentType = '', $contentMd5 = '') {
$c = fb_oss_cfg();
$cred = fb_oss_credentials();
$tok = isset($cred['token']) ? (string)$cred['token'] : '';
$res = '/' . trim((string)$c['bucket']) . '/' . ltrim((string)$key, '/');
if ($tok !== '') { $res .= '?security-token=' . $tok; }
$st = strtoupper($method) . "\n" . $contentMd5 . "\n" . $contentType . "\n"
. (int)$expires . "\n" . $res;
$out = [
'OSSAccessKeyId' => (string)$cred['ak'],
'Expires' => (int)$expires,
'Signature' => base64_encode(hash_hmac('sha1', $st, (string)$cred['sk'], true)),
];
// security-token 除了参与上面的签名计算,也仍要作为 URL 参数发出去
if ($tok !== '') { $out['security-token'] = $tok; }
return $out;
}
/** 给对象 URL 追加签名参数(私有 Bucket 展示时用;公共读时返回原 URL) */
function fb_oss_url($key, $ttl = 0) {
$url = fb_oss_object_url($key);
if (strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') { return $url; }
$ttl = $ttl > 0 ? (int)$ttl : (int)fb_oss_cfg('link_ttl', 604800);
$sign = fb_oss_sign_v1('GET', $key, time() + $ttl);
return $url . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
}
/**
* 生成浏览器直传所需的上传凭证
* @param string $key 服务端指定的对象键(策略里用 eq 锁定,客户端无法改名)
* @param int $maxBytes content-length-range 上限
* @param string $typePrefix 允许的 Content-Type 前缀(image/ 或 video/)
* @return array
*/
function fb_oss_upload_credential($key, $maxBytes, $typePrefix) {
$c = fb_oss_cfg();
$cred = fb_oss_credentials();
$ttl = max(60, (int)fb_oss_cfg('sign_ttl', 600));
$expire = time() + $ttl;
// 临时凭证本身可能更快到期 —— 策略有效期必须落在凭证有效期内,否则 OSS 会拒
if (!empty($cred['expire']) && (int)$cred['expire'] > 0) {
$expire = min($expire, (int)$cred['expire'] - 30);
if ($expire <= time()) { $expire = time() + 60; }
}
$policy = [
'expiration' => gmdate('Y-m-d\TH:i:s\Z', $expire),
'conditions' => [
['bucket' => (string)$c['bucket']],
['eq', '$key', (string)$key],
['starts-with', '$Content-Type', (string)$typePrefix],
['content-length-range', 1, (int)$maxBytes],
],
];
$json = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$b64 = base64_encode($json);
// 使用实例 RAM 角色的临时凭证时,表单里必须多带一个 x-oss-security-token 域
$extra = [];
if (!empty($cred['token'])) { $extra['x-oss-security-token'] = (string)$cred['token']; }
return [
'host' => fb_oss_host(), // 浏览器 POST 到这里
'key' => (string)$key,
'policy' => $b64,
// ⚠️ 用本次已取出的 $cred 计算,别再去调 fb_oss_sk()/fb_oss_ak()
// (多调一次 fb_oss_credentials() 可能在刷新后拿到不同批次)
'signature' => fb_oss_sign_policy_string($b64, (string)$cred['sk']),
'access_key_id' => (string)$cred['ak'],
'content_type' => (string)$typePrefix,
'max_bytes' => (int)$maxBytes,
'expires' => $expire,
'extra_fields' => $extra, // 恒含 x-oss-security-token
'url' => fb_oss_url($key),
];
}
/**
* 极简 HTTP 客户端:curl 优先,无 curl 时用 stream 兜底。
* @return array [$status, $headers(lowercase keys), $body]
*/
function fb_http($method, $url, array $opt = []) {
$method = strtoupper((string)$method);
$timeout = isset($opt['timeout']) ? max(1, (int)$opt['timeout']) : 8;
$headers = isset($opt['headers']) ? (array)$opt['headers'] : [];
$noproxy = !empty($opt['noproxy']);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
]);
// 元数据服务走链路本地地址,绝不能经过 http_proxy(否则永远读不到凭证)
if ($noproxy && defined('CURLOPT_NOPROXY')) { curl_setopt($ch, CURLOPT_NOPROXY, '*'); }
if ($method === 'HEAD') { curl_setopt($ch, CURLOPT_NOBODY, true); }
$raw = curl_exec($ch);
$bad = ($raw === false) || curl_errno($ch);
$hsz = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$st = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($bad) { return [0, [], '']; }
return [$st, fb_parse_headers(substr((string)$raw, 0, $hsz)), (string)substr((string)$raw, $hsz)];
}
$streamOpt = [
'method' => $method,
'timeout' => $timeout,
'ignore_errors' => true,
'header' => implode("\r\n", $headers),
];
if ($noproxy) { $streamOpt['proxy'] = ''; $streamOpt['request_fulluri'] = false; }
$ctx = stream_context_create(['http' => $streamOpt]);
$body = @file_get_contents($url, false, $ctx);
$status = 0; $rawHead = '';
if (isset($http_response_header) && is_array($http_response_header)) {
$lines = [];
foreach ($http_response_header as $line) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $line, $m)) { $status = (int)$m[1]; $lines = []; }
else { $lines[] = $line; }
}
$rawHead = implode("\r\n", $lines);
}
return [$status, fb_parse_headers($rawHead), ($body === false ? '' : $body)];
}
/** 解析响应头为小写键数组 */
function fb_parse_headers($raw) {
$out = [];
foreach (preg_split('/\r\n|\n/', (string)$raw) as $line) {
$p = strpos($line, ':');
if ($p === false) { continue; }
$out[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1));
}
return $out;
}
/**
* HEAD 一个对象,用于提交时复核(是否存在 / 大小 / 类型)
* @return array [$status, $headers]
*/
function fb_oss_head($key) {
// 一律带签名:使用 STS 临时凭证时 security-token 必须随请求发出,
// 否则私有 Bucket、或要求「经过身份验证的请求」的场景会被 OSS 直接 403。
$signed = fb_oss_object_url($key, true) . '?'
. http_build_query(fb_oss_sign_v1('HEAD', $key, time() + 300), '', '&', PHP_QUERY_RFC3986);
list($status, $h, ) = fb_http('HEAD', $signed, ['timeout' => 6]);
if ($status === 0) {
// 某些环境禁用了 curl 的 NOBODY,退化为 GET 只取头
list($status, $h, ) = fb_http('GET', $signed, ['timeout' => 6]);
}
// 公共读 Bucket 的兜底:签名请求若因「该身份没有 GetObject 权限」被拒,
// 改走匿名请求(公共读对象匿名即可读取),避免误判为「对象不存在」。
if ($status === 403 && strtolower((string)fb_oss_cfg('visibility', 'public')) !== 'private') {
$anon = fb_oss_object_url($key, true);
list($s2, $h2, ) = fb_http('HEAD', $anon, ['timeout' => 6]);
if ($s2 === 0) { list($s2, $h2, ) = fb_http('GET', $anon, ['timeout' => 6]); }
if ($s2 !== 0) { return [$s2, $h2]; }
}
return [$status, $h];
}
/** 删除一个对象(清理过期附件时用)。返回是否成功(404 视作已删除) */
function fb_oss_delete($key) {
$exp = time() + 300;
$sign = fb_oss_sign_v1('DELETE', $key, $exp);
$url = fb_oss_object_url($key, true) . '?' . http_build_query($sign, '', '&', PHP_QUERY_RFC3986);
list($status, , ) = fb_http('DELETE', $url, ['timeout' => 8]);
return ($status >= 200 && $status < 300) || $status === 404;
}
/**
* 列表/灯箱用的缩放地址:借用 OSS 图片处理,省流量(可关闭)
* @param string $mime 已知的内容类型;GIF 会被 OSS 处理成静态首帧(动画丢失),故原样返回
*/
function fb_oss_thumb_url($key, $w = 560, $mime = '') {
$url = fb_oss_url($key);
if (!fb_oss_cfg('image_process', true)) { return $url; }
$m = strtolower(trim((string)$mime));
if ($m === '' || $m === 'application/octet-stream') {
$e = strtolower((string)pathinfo((string)$key, PATHINFO_EXTENSION));
if ($e === 'gif') { return $url; }
} elseif ($m === 'image/gif') {
return $url;
}
$sep = (strpos($url, '?') === false) ? '?' : '&';
return $url . $sep . 'x-oss-process=image/resize,w_' . (int)$w . '/quality,q_80';
}
+307
View File
@@ -0,0 +1,307 @@
<?php
/**
* 通用工具函数
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** HTML 转义(所有用户内容输出前必须经过它) */
function e($s) {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** 相对时间显示 */
function fb_fmt_time($ts) {
$ts = (int)$ts;
$diff = time() - $ts;
if ($diff < 0) { $diff = 0; }
if ($diff < 60) { return '刚刚'; }
if ($diff < 3600) { return ((int)floor($diff / 60)) . ' 分钟前'; }
if ($diff < 86400) { return ((int)floor($diff / 3600)) . ' 小时前'; }
if ($diff < 604800) { return ((int)floor($diff / 86400)) . ' 天前'; }
if (date('Y', $ts) === date('Y')) { return date('m-d H:i', $ts); }
return date('Y-m-d H:i', $ts);
}
/** 访客 IP(兼容反向代理 / CDN) */
function fb_client_ip() {
$keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($keys as $k) {
if (!empty($_SERVER[$k])) {
$v = explode(',', (string)$_SERVER[$k]);
$v = trim($v[0]);
if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; }
}
}
return '0.0.0.0';
}
/** 访客 IP 的加盐哈希(不保存明文 IP) */
function fb_ip_hash() {
return hash('sha256', fb_client_ip() . '|' . (string)fb_cfg('ip_salt'));
}
/** 站点基地址(优先取配置,否则自动探测) */
function fb_base_url() {
$b = (string)fb_cfg('base_url', '');
if ($b !== '') { return rtrim($b, '/'); }
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
$scheme = $https ? 'https' : 'http';
$host = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
$script = isset($_SERVER['SCRIPT_NAME']) ? (string)$_SERVER['SCRIPT_NAME'] : '/index.php';
$dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
return $scheme . '://' . $host . $dir;
}
/** 跳转并结束 */
function fb_redirect($url) {
header('Location: ' . $url);
exit;
}
/** 分类 / 状态 */
function fb_categories() { return (array)fb_cfg('categories', []); }
function fb_category_label($k) {
$c = fb_categories();
return isset($c[$k]) ? $c[$k] : '其他';
}
function fb_statuses() { return (array)fb_cfg('statuses', []); }
function fb_status_label($k) {
$s = fb_statuses();
return isset($s[$k]) ? $s[$k] : (string)$k;
}
/** CSRF */
function fb_csrf_token() {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function fb_csrf_ok($t) {
return !empty($_SESSION['csrf']) && is_string($t) && hash_equals($_SESSION['csrf'], $t);
}
/** 频率限制 */
function fb_rate_check($action, $limit) {
$pdo = fb_db();
$since = time() - 3600;
$st = $pdo->prepare('SELECT COUNT(*) FROM rate_limit WHERE ip_hash=? AND action=? AND created_at>=?');
$st->execute([fb_ip_hash(), $action, $since]);
return ((int)$st->fetchColumn()) < (int)$limit;
}
function fb_rate_hit($action) {
$pdo = fb_db();
$st = $pdo->prepare('INSERT INTO rate_limit(ip_hash, action, created_at) VALUES(?,?,?)');
$st->execute([fb_ip_hash(), $action, time()]);
// 顺手清理 2 小时前的记录
if (random_int(1, 20) === 1) {
$pdo->exec('DELETE FROM rate_limit WHERE created_at < ' . (time() - 7200));
}
}
/** 文本长度(按字符计) */
function fb_len($s) {
return function_exists('mb_strlen') ? mb_strlen((string)$s, 'UTF-8') : strlen((string)$s);
}
/** 截断文本 */
function fb_excerpt($s, $len = 160) {
$s = trim(preg_replace('/\s+/u', ' ', (string)$s));
if (fb_len($s) <= $len) { return $s; }
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len)) . '…';
}
/** 校验昵称:1~24 字符,不含换行 */
function fb_valid_name($s) {
$s = trim((string)$s);
if ($s === '' || fb_len($s) > 24) { return false; }
if (preg_match('/[\r\n\t]/', $s)) { return false; }
return true;
}
/** 校验正文:2~5000 字符 */
function fb_valid_body($s) {
$s = trim((string)$s);
$n = fb_len($s);
return ($n >= 2 && $n <= 5000);
}
/** 构建当前查询串(保留筛选条件) */
function fb_qs(array $over = []) {
$q = $_GET;
foreach ($over as $k => $v) {
if ($v === null) { unset($q[$k]); } else { $q[$k] = $v; }
}
$q = array_filter($q, function ($v) { return $v !== '' && $v !== null; });
return $q ? ('?' . http_build_query($q)) : '';
}
/* ---------------- 审核(先审后显) ---------------- */
/** 审核总开关:开启后新提交的问题默认不公开,需管理员审核通过才显示 */
function fb_moderation_on() {
return (bool)fb_cfg('moderation.enabled', true);
}
/** 当前访问者是否已登录审核后台(admin.php 登录后置位) */
function fb_is_admin() {
return !empty($_SESSION['fb_admin']) && $_SESSION['fb_admin'] === 1;
}
/** 该问题对当前访客可见吗?(管理员可看全部) */
function fb_thread_public(array $thread) {
if (!fb_moderation_on()) { return true; }
if (isset($thread['visible']) && (int)$thread['visible'] === 1) { return true; }
return fb_is_admin();
}
/* ---------------- 站点设置(数据库覆盖 config.php) ----------------
* 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项;
* config.php 因此变成「出厂默认值」,部署后不必再手工改它。
*/
/** 允许在后台修改的设置键(白名单,防止改到 db_path 之类) */
function fb_setting_keys() {
return [
'site_name', 'site_desc',
'attachment.storage',
'upload.image_max_per_post', 'upload.image_max_bytes',
'upload.video_max_per_post', 'upload.video_max_bytes',
'upload.total_cap_bytes', 'upload.retention_days',
'upload.upload_per_hour', 'upload.image_max_side',
'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled',
'oss.credential_source', 'oss.access_key', 'oss.secret_key',
'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme',
'oss.verify', 'oss.image_process',
'moderation.enabled', 'moderation.close_mail_subject',
'admin.password_hash',
];
}
/**
* 全部设置(扁平:'oss.bucket' => 值)。带进程内缓存;$reload=true 强制重读。
* 与 fb_db() 之间可能互相调用,用 $loading 防止递归。
*/
function fb_settings_flat($reload = false) {
static $flat = null;
static $loading = false;
if ($flat !== null && !$reload) { return $flat; }
if ($loading) { return []; }
$loading = true;
$out = [];
try {
$pdo = fb_db();
$rows = $pdo->query('SELECT key, value FROM settings')->fetchAll();
foreach ($rows as $r) {
$k = (string)$r['key'];
$v = json_decode((string)$r['value'], true);
if (json_last_error() !== JSON_ERROR_NONE) { $v = (string)$r['value']; }
$out[$k] = $v;
}
} catch (Throwable $e) {
$out = [];
}
$loading = false;
$flat = $out;
return $flat;
}
/** 把扁平的 'a.b.c' => v 转成嵌套数组,供 fb_cfg 与文件配置合并 */
function fb_setting_tree() {
$tree = [];
foreach (fb_settings_flat() as $k => $v) {
$parts = explode('.', (string)$k);
$cur =& $tree;
foreach ($parts as $i => $p) {
if ($i === count($parts) - 1) { $cur[$p] = $v; }
else {
if (!isset($cur[$p]) || !is_array($cur[$p])) { $cur[$p] = []; }
$cur =& $cur[$p];
}
}
unset($cur);
}
return $tree;
}
/** 读单个设置(未经白名单限制,仅用于内部读取) */
function fb_setting($key, $default = null) {
$flat = fb_settings_flat();
return array_key_exists($key, $flat) ? $flat[$key] : $default;
}
/** 写单个设置 @return bool 是否成功(不在白名单里会失败) */
function fb_set_setting($key, $value) {
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
$pdo = fb_db();
$st = $pdo->prepare('INSERT OR REPLACE INTO settings(key, value, updated_at) VALUES(?,?,?)');
$st->execute([(string)$key, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), time()]);
// 立刻失效:本进程内再读 fb_cfg 也要拿到新值(后台保存后同页要显示新状态)
fb_settings_flat(true);
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
return true;
}
/** 删除单个设置(回落为 config.php 里的出厂值) @return bool */
function fb_unset_setting($key) {
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
$pdo = fb_db();
$st = $pdo->prepare('DELETE FROM settings WHERE key = ?');
$st->execute([(string)$key]);
// 同样立刻失效,保证后续 fb_cfg 读到的是一致状态
fb_settings_flat(true);
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
return true;
}
/* ---------------- 管理员登录 ---------------- */
/** 后台会话的有效期(秒):超时自动退出 */
function fb_admin_idle_ttl() {
return 7200;
}
/** 已登录且在有效期内(每次访问续期) */
function fb_admin_logged_in() {
if (!fb_is_admin()) { return false; }
$at = isset($_SESSION['fb_admin_at']) ? (int)$_SESSION['fb_admin_at'] : 0;
if ($at <= 0 || (time() - $at) > fb_admin_idle_ttl()) {
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
return false;
}
$_SESSION['fb_admin_at'] = time();
return true;
}
/** 置位登录态 */
function fb_admin_login() {
$_SESSION['fb_admin'] = 1;
$_SESSION['fb_admin_at'] = time();
@session_regenerate_id(true);
}
/**
* 校验管理员口令。
* 后台改过口令后存的是 password_hash;没改过就用 config.php 的 admin_password。
*/
function fb_admin_verify_password($pw) {
$pw = (string)$pw;
$hash = (string)fb_setting('admin.password_hash', '');
if ($hash !== '') { return password_verify($pw, $hash); }
$cfg = (string)fb_cfg('admin_password', '');
return $cfg !== '' && hash_equals($cfg, $pw);
}
/** 修改管理员口令(存哈希到数据库,立即生效) */
function fb_admin_set_password($pw) {
return fb_set_setting('admin.password_hash', password_hash((string)$pw, PASSWORD_DEFAULT));
}
/** 写一条操作日志 */
function fb_mod_log($action, $threadId = null, $detail = '') {
try {
$pdo = fb_db();
$st = $pdo->prepare('INSERT INTO mod_log(thread_id, action, detail, created_at, ip_hash) VALUES(?,?,?,?,?)');
$st->execute([$threadId ? (int)$threadId : null, (string)$action, (string)$detail, time(), fb_ip_hash()]);
} catch (Throwable $e) { /* 日志失败不影响主流程 */ }
}