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(); } /* ---------------- 安装状态 ---------------- */ /** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */ function fb_credit_url_default() { return 'https://code.yangyq.net/laoyang/FeedLite'; } /** * 是否已安装:数据库文件存在且非空。 * ★ 必须用 fb_cfg_file()(纯文件配置)而不是 fb_cfg(): * 后者会去读 settings 表 -> 触发 fb_db() -> 就地建出一个空库, * 那样第二秒就永远进不去安装向导了(安装态会被自己"装"出来)。 */ function fb_installed() { $p = (string)fb_cfg_file()['db_path'] ?? ''; return ($p !== '' && is_file($p) && (int)@filesize($p) > 0); } /* ---------------- 站点设置(数据库覆盖 config.php) ---------------- * 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项; * config.php 因此变成「出厂默认值」,部署后不必再手工改它。 */ /** 允许在后台修改的设置键(白名单,防止改到 db_path 之类) */ function fb_setting_keys() { return [ 'site_name', 'site_desc', 'footer.show_credit', 'footer.credit_url', 'installed_at', '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', 'oss.use_internal_endpoint', '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 []; } /* ★ 未安装时绝不读表:读表要先 fb_db(),那会就地建出一个空数据库文件, * 等于把「未安装」这个状态自己抹掉 —— 安装向导就再也进不去了。 * fb_installed() 走纯文件配置、不碰库,可以安全地在这里调用。 */ if (function_exists('fb_installed') && !fb_installed()) { 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); } /** 安装向导会直接写成对的占位值;这些值一律视为「没设口令」 */ function fb_admin_password_placeholder() { return ['', 'CHANGE_ME_admin', 'CHANGE_ME', 'change_me', 'admin', 'password']; } /** * 口令当前存在哪 —— 用于在后台如实告诉管理员「口令保存在哪里、是否加密」。 * ① 'db' :数据库 settings 表的 admin.password_hash,bcrypt 单向哈希(不可逆) * ② 'file' :config.php 的 admin_password 明文(仅安装向导之前的老部署会走到) * ③ 'none' :尚未设置(或仍是出厂占位值)→ 后台拒绝登录,只能用安装向导设一个 */ function fb_admin_password_source() { if ((string)fb_setting('admin.password_hash', '') !== '') { return 'db'; } $cfg = trim((string)fb_cfg('admin_password', '')); if ($cfg === '' || in_array($cfg, fb_admin_password_placeholder(), true)) { return 'none'; } return 'file'; } /** 是否已设置过可用口令(未设置时后台不可登录,且不提示"口令不正确"而是提示去安装) */ function fb_admin_password_is_set() { return fb_admin_password_source() !== 'none'; } /** * 校验管理员口令。 * 后台改过口令后存的是 password_hash;没改过才回落到 config.php 的明文。 * ★ 出厂占位值(CHANGE_ME_admin 等)一律拒绝:否则 clone 下来没改配置的站点, * 任何人都能用文档里公开的占位口令登进后台。 */ 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', ''); if ($cfg === '' || in_array($cfg, fb_admin_password_placeholder(), true)) { return false; } return hash_equals($cfg, $pw); } /** 修改管理员口令(存 bcrypt 哈希到数据库,立即生效) */ 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) { /* 日志失败不影响主流程 */ } }