需求落实: 1. 页脚常驻「管理后台」链接,不用再手敲 admin.php。 2. 页脚「由 FeedLite 驱动」中的 FeedLite 改为超链接(指向开源仓库); 后台新增「页脚」设置组,可关闭整句(关闭后只留管理后台链接),也可改链接地址。 3. 管理员口令存储方式在后台如实展示:数据库 settings.admin.password_hash, 存 bcrypt 单向哈希(不可逆),明文不落盘。 4. 新增安装向导 install.php:data/ 下没有数据库时自动 302 引导; 体检环境 -> 填站点信息与口令 -> 生成 config.php -> 建库建表 -> 完成。 顺带修掉一个真实安全隐患: config.php 里的出厂占位口令 CHANGE_ME_admin 原先可被当作有效口令登录后台 (等于公开仓库里就写着线上后门口令)。现改为占位/空值一律拒绝, 未设口令时后台明确提示去安装向导,不再含糊报「口令不正确」。 实现要点: - 新增 fb_cfg_file():只读配置文件、不叠加 settings 表、绝不触达数据库。 「是否已安装」以数据库文件是否存在为判据,而 fb_cfg() 读 settings 会触发 fb_db() 就地建库 —— 那会把安装态自己造出来,所以判定必须走不碰库的通道。 - bootstrap 只在已安装时建库;未安装则 302 到 install.php(CLI 下报错退出)。 - fb_settings_flat() 在未安装时直接返回空,杜绝任何隐式建库。 - install.php 用文本级替换生成 config.php,保留全部注释与 OSS/SMTP 段落; 生成的 PHP 用 token_get_all(TOKEN_PARSE) 校验后才落盘。 - 页脚链接只放行 http/https,后台保存时校验(防 javascript: 伪协议)。 - style.css 页脚行改 flex,版本号 v6 -> v7。 验证: - php -l 全量通过;现有回归 25/25、附件专项 13/13 保持通过。 - 安装向导专项 60/60(含「明文口令未写入任何文件」「数据库字节里搜不到明文」)。 - 反向验证:撤销三处修复后对应断言确实失败(断言非装饰)。 - 几何检查:本机 15/15 + 页脚专项全过;线上真实 URL 15/15 + 页脚专项全过。 - 线上真机冒烟 22/22。
358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
<?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));
|
||
}
|
||
|
||
/** 写一条操作日志 */
|
||
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) { /* 日志失败不影响主流程 */ }
|
||
}
|