Files
FeedLite/lib/util.php
T
laoyang f1a81cbaa1 RAM 角色自动探测 + 邮件配置界面 + 每个软件独立主题色 (#1 #2)
## 1. ECS RAM 角色改为自动探测
原先要用户去阿里云控制台查「实例 RAM 角色名」再手填,纯属多余 ——
机器自己知道答案。

新增 fb_oss_detect_instance_role():读元数据
GET /latest/meta-data/ram/security-credentials/(末尾带斜杠)拿到角色名列表。
- 兼容单行 / 多行 / JSON 数组三种返回
- 未配置角色时 fb_oss_instance_creds() 自动探测,不再报「未配置」
- 后台改成只读展示「已绑定:xxx」,手填入口收进「高级」折叠里
- 保存时留空 = 自动探测后写入,不存空值
- 探测失败给出可操作原因(没绑角色 / 不在 ECS 上 / 元数据被禁用)

## 2. 邮件配置界面(原先完全缺失)
lib/mailer.php 有完整 SMTP 实现,但后台一个输入框都没有,
只能改 config.php —— 用户装完根本找不到地方配。

根因有两层:
- fb_setting_keys() 白名单里没有任何 smtp.* / notify.* 键,
  就算加了表单,fb_set_setting() 也会把值全部丢弃
- 设置页只有「站点信息」「附件存储」两个区块

补上:白名单 15 个键 + 后台「邮件通知」区块
- SMTP 服务器/端口/加密方式(SSL/STARTTLS/不加密)
- 登录账号/密码(留空不修改,避免每次重填)
- 发件地址/显示名、超时、SSL 校验
- 通知规则:回复通知、每次回复都通知、新反馈通知管理员、发信冷却
- 「发送测试邮件」按钮,AJAX 发完就地显示成功或失败原因,
  不用保存再靠猜

## 3. 每个软件一个主题色
需求:从短链接进不同软件,颜色不同,一眼区分。

- software 表加 color 字段(幂等迁移)
- 全站配色靠 --brand / --brand-600 / --brand-soft 三个变量,
  按软件覆写这三个即可,不用为每个软件写一套 CSS
- fb_color_variants() 由主色推出深一档(×0.85)与浅一档(往白混 88%)
- 未配色的软件按 id 从 8 色调色板取默认色,保证同站不撞色
- 后台:颜色选择器(原生取色 + 8 个预设 + 用默认)
- 前台:index / new / thread 都按当前软件注入主题色,
  meta theme-color 也跟着变

验证:三功能专项 30/30;新旧回归全过
(全新安装 33/33、老基线 25/25、附件 22/22、安装向导 60/60、约束检查 20/20)
2026-11-01 16:39:09 +08:00

641 lines
24 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
/**
* 通用工具函数
*/
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;
}
/** ================= 软件 / 分类 / 状态 =================
* 三者全部存数据库、后台可配,不再读配置文件。
* 结构:软件 → 分类(两级)。分类属于某个软件,不同软件可有同名分类。 */
/** 全部软件(按排序),$onlyEnabled=true 时只取启用的 */
function fb_softwares($onlyEnabled = true) {
$sql = 'SELECT * FROM software';
if ($onlyEnabled) { $sql .= ' WHERE enabled = 1'; }
$sql .= ' ORDER BY sort_order, id';
$rows = fb_db()->query($sql)->fetchAll();
$out = [];
foreach ($rows as $r) { $out[] = $r; }
return $out;
}
/** 按 slug 或 id 取一个软件;取不到返回 null */
function fb_software($key) {
if ($key === null || $key === '') { return null; }
$pdo = fb_db();
if (ctype_digit((string)$key)) {
$st = $pdo->prepare('SELECT * FROM software WHERE id = ? LIMIT 1');
$st->execute([(int)$key]);
} else {
$st = $pdo->prepare('SELECT * FROM software WHERE slug = ? LIMIT 1');
$st->execute([(string)$key]);
}
$r = $st->fetch();
return $r ? $r : null;
}
/** 默认软件:排序最前的启用软件;一个都没有时返回 null */
function fb_default_software() {
$list = fb_softwares(true);
return $list ? $list[0] : null;
}
/**
* 校验并规范化主题色(#RRGGBB)。
* @return string 合法则返回小写 6 位十六进制;非法返回空串
*/
function fb_color_norm($c) {
$c = strtolower(trim((string)$c));
if ($c === '') { return ''; }
if ($c[0] !== '#') { $c = '#' . $c; }
if (!preg_match('/^#[0-9a-f]{6}$/', $c)) { return ''; }
return $c;
}
/**
* 由主色推导出「深一档」和「浅一档」两个色,供 --brand-600 / --brand-soft 用。
* 不做真实色彩空间换算,只按通道线性调:深的一档乘 0.85,浅的往白色混 88%。
* 对区分软件这个目的来说足够,也不会因为极端颜色算出难看的对比。
*
* @return array [$hex, $hexDark, $hexSoft]
*/
function fb_color_variants($hex) {
$hex = fb_color_norm($hex);
if ($hex === '') { return ['', '', '']; }
$r = hexdec(substr($hex, 1, 2));
$g = hexdec(substr($hex, 3, 2));
$b = hexdec(substr($hex, 5, 2));
$dark = sprintf('#%02x%02x%02x',
max(0, (int)round($r * 0.85)),
max(0, (int)round($g * 0.85)),
max(0, (int)round($b * 0.85)));
// 往白色混 88%,得到很淡的底色(用于 badge / 选中态背景)
$mix = 0.88;
$soft = sprintf('#%02x%02x%02x',
(int)round($r + (255 - $r) * $mix),
(int)round($g + (255 - $g) * $mix),
(int)round($b + (255 - $b) * $mix));
return [$hex, $dark, $soft];
}
/**
* 给某个软件挑一个默认主题色(未配置时用)。
* 按软件的排序位次从调色板里取,保证同一站点里不同软件颜色不撞。
*/
function fb_color_default_for($softwareId) {
$palette = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
$id = max(0, (int)$softwareId);
return $palette[$id % count($palette)];
}
/**
* 取软件的主题色三元组(主色 / 深 / 浅)。
* 没配就用默认调色板,永远返回可用值。
* @return array [hex, hexDark, hexSoft]
*/
function fb_software_colors($software) {
$hex = '';
if (is_array($software)) {
$hex = fb_color_norm(isset($software['color']) ? $software['color'] : '');
if ($hex === '') { $hex = fb_color_default_for(isset($software['id']) ? $software['id'] : 0); }
}
return fb_color_variants($hex);
}
/** 某软件下的分类;不传软件则返回空数组 */
function fb_categories($softwareId = null, $onlyEnabled = true) {
if ($softwareId === null) { return []; }
$sql = 'SELECT * FROM categories WHERE software_id = ?';
$args = [(int)$softwareId];
if ($onlyEnabled) { $sql .= ' AND enabled = 1'; }
$sql .= ' ORDER BY sort_order, id';
$st = fb_db()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/** 按 id 取分类(含禁用的,后台编辑用) */
function fb_category($id) {
if (!$id) { return null; }
$st = fb_db()->prepare('SELECT * FROM categories WHERE id = ? LIMIT 1');
$st->execute([(int)$id]);
$r = $st->fetch();
return $r ? $r : null;
}
/** 在某软件内按 slug 取分类 */
function fb_category_by_slug($softwareId, $slug) {
if (!$softwareId || $slug === '') { return null; }
$st = fb_db()->prepare('SELECT * FROM categories WHERE software_id = ? AND slug = ? LIMIT 1');
$st->execute([(int)$softwareId, (string)$slug]);
$r = $st->fetch();
return $r ? $r : null;
}
/** 分类显示名:只给 id 就能拿到名字(列表渲染用) */
function fb_category_label($id) {
if (!$id) { return ''; }
static $cache = [];
$id = (int)$id;
if (!isset($cache[$id])) {
$c = fb_category($id);
$cache[$id] = $c ? (string)$c['name'] : '';
}
return $cache[$id];
}
/** 全部状态(按排序) */
function fb_statuses($onlyEnabled = true) {
$sql = 'SELECT * FROM statuses';
if ($onlyEnabled) { $sql .= ' WHERE enabled = 1'; }
$sql .= ' ORDER BY sort_order, id';
return fb_db()->query($sql)->fetchAll();
}
/** 状态 key 列表(校验用) */
function fb_status_keys() {
$out = [];
foreach (fb_statuses(true) as $s) { $out[(string)$s['key_name']] = (string)$s['name']; }
return $out;
}
/** 状态显示名 */
function fb_status_label($k) {
static $cache = null;
if ($cache === null) {
$cache = [];
foreach (fb_statuses(false) as $s) { $cache[(string)$s['key_name']] = (string)$s['name']; }
}
$k = (string)$k;
return isset($cache[$k]) ? $cache[$k] : $k;
}
/** 状态的「给用户看的话」——进邮件正文,后台可改 */
function fb_status_user_note($k) {
static $cache = null;
if ($cache === null) {
$cache = [];
foreach (fb_statuses(false) as $s) {
$cache[(string)$s['key_name']] = (string)($s['user_note'] === null ? '' : $s['user_note']);
}
}
$k = (string)$k;
return isset($cache[$k]) ? $cache[$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();
}
/* ---------------- 安装状态 ---------------- */
/** 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',
'admin.username',
// 邮件通知(SMTP)—— 原先完全没有,后台配不了,只能改 config.php
'smtp.enabled',
'smtp.host', 'smtp.port', 'smtp.secure',
'smtp.user', 'smtp.pass',
'smtp.from', 'smtp.from_name',
'smtp.timeout', 'smtp.verify_ssl',
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
'notify.admin_new_thread',
];
}
/**
* 全部设置(扁平:'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));
}
/* ---------------- 管理员账号(用户名 + 口令) ----------------
* 用户名存数据库(admin.username),口令存 bcrypt 哈希(admin.password_hash)。
* 两者缺一不可:用户名不存在时也照常走一次 password_verify,避免用响应快慢探测账号。
*/
/** 管理员用户名;没设过返回空串 */
function fb_admin_username() {
return trim((string)fb_setting('admin.username', ''));
}
/** 设置管理员用户名 */
function fb_admin_set_username($name) {
$name = trim((string)$name);
if ($name === '') { return false; }
return fb_set_setting('admin.username', $name);
}
/** 账号是否已配置(用户名 + 口令都在) */
function fb_admin_account_ready() {
return fb_admin_username() !== '' && fb_admin_password_is_set();
}
/**
* 校验账号口令。
* @return int 0=通过 1=账号或口令不对 2=还没配置账号
*/
function fb_admin_verify_login($user, $pw) {
$user = trim((string)$user);
$pw = (string)$pw;
$u = fb_admin_username();
if ($u === '' && !fb_admin_password_is_set()) { return 2; }
// 用户名没配过(老部署只设了口令):只比口令,向后兼容
if ($u === '') {
return fb_admin_verify_password($pw) ? 0 : 1;
}
$hash = (string)fb_setting('admin.password_hash', '');
// 用户名不对也照常跑一次哈希校验,保持耗时一致
$pwOk = $hash !== '' ? password_verify($pw, $hash) : fb_admin_verify_password($pw);
$uOk = hash_equals($u, $user);
return ($uOk && $pwOk) ? 0 : 1;
}
/**
* 关闭一个 curl 句柄(跨版本安全)。
* --------------------------------------------------------------
* PHP 8.0 起 CurlHandle 是对象,句柄随引用计数自动释放,curl_close() 变成空操作;
* PHP 8.5 起官方把这类"空操作函数"标为弃用,调用会写 E_DEPRECATED 日志。
* 但 PHP 7.4 上它仍是真的释放动作(项目最低支持 7.4),所以只在新版本上跳过。
*/
function fb_curl_close($ch) {
if (PHP_VERSION_ID < 80000 && $ch !== null) { @curl_close($ch); }
}
/** 关闭一个 finfo 句柄(跨版本安全;理由同 fb_curl_close) */
function fb_finfo_close($f) {
if (PHP_VERSION_ID < 80000 && $f !== null) { @finfo_close($f); }
}
/**
* PHP 8.4+ 提供 http_get_last_response_headers();PHP 8.5 起,旧的预定义局部变量
* http_response_header 已弃用(实测连 @ 抑制符都压不住,会照写 E_DEPRECATED)。
* 这里探测一次存成常量,供各调用点做三元短路 —— 关键点:未取中的分支不会求值,
* 所以新版本上根本不会去读那个已被弃用的变量。
*/
if (!defined('FB_HAS_HTTP_LAST_HDRS')) {
define('FB_HAS_HTTP_LAST_HDRS', function_exists('http_get_last_response_headers'));
}
/**
* 取「上一次 http(s) 流请求」的响应头(仅 PHP 8.4+ 可调用本函数)。
* ★ 新 API 是全局状态:若本次请求压根没发出去,它会返回 *上一次* 的残留头,
* 把旧状态当成新结果。所以要求传入请求前的基线 $before,未变化即视为「本次没有头」。
* @param array|null $before 请求前 http_get_last_response_headers() 的快照
* @return array
*/
function fb_last_http_headers($before = null) {
if (!function_exists('http_get_last_response_headers')) { return []; }
$h = http_get_last_response_headers();
$h = is_array($h) ? $h : [];
if ($before !== null && $h === (array)$before) { return []; }
return $h;
}
/**
* 从「当前作用域变量表」里取预定义变量 http_response_header(仅 PHP < 8.4 需要)。
* --------------------------------------------------------------
* 为什么不用直接读变量:PHP 8.5 对该变量的弃用是【编译期】判定的 —— 实测把它写在
* 三元运算的「不该执行」那一支里照样报警,`@` 也压不住。也就是说,只要源码里出现
* 这个标识符就会被记日志。唯一干净的做法是根本不去引用它,改用 get_defined_vars()
* 拿作用域变量表,再按键名取值。
* ⚠️ 必须在发起请求的【同一作用域】调用($vars 由调用方传 get_defined_vars())。
* @param array $vars get_defined_vars() 的结果
* @return array
*/
function fb_local_http_headers($vars) {
if (!is_array($vars)) { return []; }
$k = 'http_response_header';
if (!isset($vars[$k]) || !is_array($vars[$k])) { return []; }
return $vars[$k];
}
/** 写一条操作日志 */
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) { /* 日志失败不影响主流程 */ }
}