Files
FeedLite/lib/util.php
T
laoyang e408dc78b8 表单不再预填示例数据,示例只留 placeholder (#3)
用户反馈:输入框里预填的「示例值」点进去得先删干净才能填自己的,不合理。

必须分清两类,否则会误删真实配置:
- A 类 真实配置(从数据库读出来的)→ 保留
- B 类 出厂示例(代码/配置文件里写死的)→ 清空,示例移 placeholder

本次处理的 B 类:

1. 安装页「网站名称」
   原先回显 config.example.php 里的出厂名「轻反馈」,用户以为要保留。
   现在首次进入为空,仅在表单校验失败重来时回填用户刚才填的内容。

2. 安装页「管理员用户名」
   原先预填 admin,现在为空,placeholder 提示「例如 admin」。

3. 后台「对象键前缀」
   原先显示 fb(来自 config 默认值),现在为空,placeholder「留空即用 fb」。

4. 后台「关闭通知邮件标题」
   原先显示模板「关于你反馈的问题《%s》」,现在为空,placeholder 给出模板。

## 新增 fb_form_val()
config.example.php 里给的出厂默认值和「用户填过的值」在 fb_cfg() 里合并后
分不出来 —— 表单永远显示得出内容。这个函数按「数据库里有没有这条设置」判定:
设过就回显(哪怕值恰好等于默认),没设过就当未自定义、显示空。

## 顺带修复一个真 bug
close_mail_subject 原先留空会存成空字符串,fb_cfg() 返回空串后
sprintf() 得到空邮件标题 —— 用户清空模板就发不出有主题的邮件。
改成留空即删除该设置,回落到默认模板。

## 验证
#3 专项 20/20(含:真实配置保存后必须回显、清空后必须回落默认且不存空串);
回归全绿:全新安装 33/33、安装向导 60/60、老基线 25/25、约束检查 20/20。
2026-11-01 16:53:52 +08:00

668 lines
25 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;
}
/**
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。
*
* 为什么需要:config.example.php 里给 oss.prefix 写了 'fb'、
* close_mail_subject 写了模板,fb_cfg() 会把文件配置和数据库设置合并,
* 所以表单永远显示得出值 —— 用户点进去得先删掉这段"看起来是数据"的默认值。
*
* 用这个函数包一层:等于默认就显示空,用户看到的是干净的输入框;
* 真自己改过(值 ≠ 默认)才回显。
*
* @param string $cur 当前生效值
* @param string $default 出厂默认值
* @return string 要回显的值(等于默认则空)
*/
function fb_form_val($cur, $default, $settingKey = '') {
$cur = (string)$cur;
// 有键名时以「数据库里设过没有」为准 —— 比"值等于默认"可靠:
// 用户真把站点名写成「轻反馈」也是他自己设的,不该被当成未设置。
// 但文件配置里的出厂值不算「设过」,因为那不是用户的输入。
if ($settingKey !== '') {
$exists = fb_setting($settingKey, null) !== null;
if ($exists) { return $cur; }
return ($cur === (string)$default) ? '' : $cur;
}
return ($cur === (string)$default) ? '' : $cur;
}
/**
* 校验并规范化主题色(#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) { /* 日志失败不影响主流程 */ }
}