Files
laoyang 6135e7b890 兼容 PHP 8.5:消除全部弃用调用
PHP 8.5 把 curl_close()/finfo_close() 这类"空操作函数"标为弃用,
并把预定义局部变量 http_response_header 标为弃用(编译期判定,@ 与三元短路都压不住)。

- 句柄关闭收进 fb_curl_close()/fb_finfo_close():仅 PHP < 8.0 真正调用,
  新版本直接跳过(8.0 起句柄随引用计数释放),保留 7.4 兼容
- 响应头读取改用 http_get_last_response_headers()(8.4+);
  老版本经 get_defined_vars() 从作用域变量表取,源码不再出现已弃用标识符
- 新 API 是全局状态,用「请求前基线」比对,避免把上一次的残留头当成本次结果
- 安装体检与 README 的推荐版本区间更新为 8.1~8.5

验证:PHP 8.5.12 下五套测试全绿(25/25、22/22、60/60、7/7、反向 4 组全被抓住),
error_log 零条弃用记录;PHP 8.4.25 同样全绿。
2026-10-23 08:23:55 +08:00

417 lines
16 KiB
PHP
Raw Permalink 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;
}
/** 分类 / 状态 */
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();
}
/* ---------------- 安装状态 ---------------- */
/** 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));
}
/**
* 关闭一个 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) { /* 日志失败不影响主流程 */ }
}