用户反馈三点: 1. 顶栏图标太大(56px) 2. 顶栏文字反而比内容区还小 —— 主次颠倒 3. 不要再整页显示「选择软件:」,改成右上角三横线按钮,点开下拉选 字号标准(用户给的基准): 「选择软件」标签 13.5px,比它大两号 ≈ 17px。 → 顶栏 17px 字 / 30px 图标;内容区 15px 字 / 24px 图标。 这样 17 > 15,层级正确。 之前两轮都搞错了: - 先给顶栏 46px 图标 + 22px 字:图标过大 - 再给 56px + 26px:更大 - 而且内容区当时是 30px,比顶栏还大,视觉上「上面比下面小」 软件选择改造: - 顶栏右侧新增汉堡按钮(三横线,展开变×) - 抽屉面板放在 header 内、正文之上,默认 hidden - 列出软件:图标 + 名字 + 当前项标记 - index.php 删除整行「选择软件:」+ chips - new.php 表单内也删除那排 chip(同一选择不该出现两次) - 交互:点击展开/再点收起/点外部关闭/Esc 关闭 - 宽屏抽屉横向排列,窄屏纵向 验证(CDP 驱动真实浏览器): 汉堡按钮存在、初始收起、点击展开、aria 同步、软件条目与当前标记齐全、 再点收起、点外部关闭 —— 全部 PASS; 顶栏字号 17px > 内容区 15px,页面无裸露 ?>; 全部路由 200;窄屏 15px/13px 层级仍正确。
881 lines
34 KiB
PHP
881 lines
34 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。
|
||
*
|
||
* 🔴🔴 这里曾经是**无条件信任** X-Forwarded-For / X-Real-IP / CF-Connecting-IP:
|
||
*
|
||
* $keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
|
||
* foreach ($keys as $k) { ... return 第一个合法 IP ... }
|
||
*
|
||
* 而这些请求头是**客户端可以随便写的**。任何人加一个
|
||
* `X-Forwarded-For: 1.2.3.4` 就换了个「IP」,于是:
|
||
* - 上传限流形同虚设(实测:上限 2 次,改头后 6 次全放行)
|
||
* - 发帖限流、验证码频率、所有基于 IP 的限制全部失效
|
||
*
|
||
* 正确做法:
|
||
* 1. 默认只用 REMOTE_ADDR(客户端改不了)
|
||
* 2. 只有请求**确实来自受信任的反向代理/CDN** 时,才解析转发头
|
||
* 3. 解析时取**最右一跳**——那是最近一层代理写进去的,
|
||
* 最左边的(用户自己塞的)最不可信。取最左等于把伪造的当前者。
|
||
*
|
||
* 受信任来源通过后台配置 `trusted_proxies` 指定(逗号分隔 IP 或 CIDR)。
|
||
* 空 = 不信任任何转发头(默认,最安全)。
|
||
*/
|
||
function fb_client_ip() {
|
||
$remote = isset($_SERVER['REMOTE_ADDR']) ? trim((string)$_SERVER['REMOTE_ADDR']) : '';
|
||
if (!filter_var($remote, FILTER_VALIDATE_IP)) { $remote = ''; }
|
||
|
||
// 没有配置受信任代理 → 只能用 REMOTE_ADDR,转发头一律忽略
|
||
$trusted = fb_trusted_proxies();
|
||
if (!$trusted || $remote === '' || !fb_ip_in_ranges($remote, $trusted)) {
|
||
return $remote !== '' ? $remote : '0.0.0.0';
|
||
}
|
||
|
||
// 来自受信任代理,才读转发头;且只取最右一跳
|
||
$candidates = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'];
|
||
foreach ($candidates as $k) {
|
||
if (empty($_SERVER[$k])) { continue; }
|
||
$parts = array_filter(array_map('trim', explode(',', (string)$_SERVER[$k])));
|
||
if (!$parts) { continue; }
|
||
$last = end($parts); // ★ 最右一跳
|
||
if (filter_var($last, FILTER_VALIDATE_IP)) { return $last; }
|
||
}
|
||
return $remote;
|
||
}
|
||
|
||
/** 受信任的反向代理列表(后台配置,逗号分隔 IP / CIDR)@return string[] */
|
||
function fb_trusted_proxies() {
|
||
static $cache = null;
|
||
if ($cache !== null) { return $cache; }
|
||
$raw = (string)fb_cfg('trusted_proxies', '');
|
||
$out = [];
|
||
foreach (preg_split('/[\s,]+/', $raw) as $one) {
|
||
$one = trim($one);
|
||
if ($one !== '') { $out[] = $one; }
|
||
}
|
||
$cache = $out;
|
||
return $cache;
|
||
}
|
||
|
||
/**
|
||
* IP 是否落在某个 CIDR / 精确 IP 列表里。支持 IPv4 与 IPv6 精确匹配,
|
||
* IPv6 只做精确比较(不做前缀,够用且不易出错)。
|
||
*/
|
||
function fb_ip_in_ranges($ip, array $ranges) {
|
||
$ipBin = @inet_pton($ip);
|
||
if ($ipBin === false) { return false; }
|
||
foreach ($ranges as $r) {
|
||
if (strpos($r, '/') === false) {
|
||
$b = @inet_pton($r);
|
||
if ($b !== false && $b === $ipBin) { return true; }
|
||
continue;
|
||
}
|
||
list($net, $bits) = explode('/', $r, 2);
|
||
$netBin = @inet_pton(trim($net));
|
||
if ($netBin === false) { continue; }
|
||
$bits = (int)$bits;
|
||
// 只处理同族(长度一致)的情况
|
||
if (strlen($netBin) !== strlen($ipBin)) { continue; }
|
||
if ($bits <= 0) { return true; }
|
||
$maxBits = strlen($netBin) * 8;
|
||
if ($bits > $maxBits) { $bits = $maxBits; }
|
||
$bytes = intdiv($bits, 8);
|
||
$rem = $bits % 8;
|
||
if ($bytes > 0 && substr($netBin, 0, $bytes) !== substr($ipBin, 0, $bytes)) { continue; }
|
||
if ($rem === 0) { return true; }
|
||
$mask = 0xFF << (8 - $rem) & 0xFF;
|
||
if ((ord($netBin[$bytes]) & $mask) === (ord($ipBin[$bytes]) & $mask)) { return true; }
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** 访客 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;
|
||
}
|
||
|
||
/**
|
||
* 由帖子行取它所属的软件行(邮件/页面要显示「这是对哪个软件提交的」都用它)。
|
||
* threads 只存 software_id,这里统一解析,避免各处自己写 SQL 漏掉。
|
||
* 取不到时回落到默认软件,保证邮件里永远有软件名可显示。
|
||
*/
|
||
function fb_software_of_thread(array $thread) {
|
||
$id = isset($thread['software_id']) ? (int)$thread['software_id'] : 0;
|
||
if ($id > 0) {
|
||
$sw = fb_software($id);
|
||
if ($sw) { return $sw; }
|
||
}
|
||
return fb_default_software();
|
||
}
|
||
|
||
/**
|
||
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。
|
||
*
|
||
* 为什么需要: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);
|
||
}
|
||
|
||
/**
|
||
* 规范化软件图标字符。
|
||
* 只留前几个字符,去掉换行与 HTML 尖括号 —— 这个值会直接输出到页面和邮件里,
|
||
* 放进 <span> 之间。虽然后面统一走 e() 转义,但源头收窄一层更稳。
|
||
*/
|
||
function fb_sw_icon_norm($s) {
|
||
$s = trim(strip_tags((string)$s));
|
||
$s = preg_replace('/[\x00-\x1F\x7F]+/u', '', $s);
|
||
if ($s === null || $s === '') { return ''; }
|
||
return mb_substr($s, 0, 4, 'UTF-8');
|
||
}
|
||
|
||
/** 规范化软件的下载地址:只接受 http/https,其它一律丢空 */
|
||
function fb_sw_url_norm($s) {
|
||
$s = trim((string)$s);
|
||
if ($s === '') { return ''; }
|
||
if (!preg_match('~^https?://~i', $s)) { return ''; }
|
||
return mb_substr($s, 0, 300, 'UTF-8');
|
||
}
|
||
|
||
/**
|
||
* 某软件的图标字符(单字符:emoji / 汉字 / 字母)。
|
||
* 没配就按软件名首字兜底 —— 多软件并存时,任何地方都不该出现「无图标」的空档。
|
||
*/
|
||
function fb_software_icon($software) {
|
||
$ic = '';
|
||
if (is_array($software)) {
|
||
$ic = trim((string)(isset($software['icon']) ? $software['icon'] : ''));
|
||
if ($ic === '') {
|
||
// 兜底:取名称首字(中文取第一个字,英文取首字母大写)
|
||
$nm = trim((string)(isset($software['name']) ? $software['name'] : ''));
|
||
if ($nm !== '') {
|
||
if (preg_match('/^[A-Za-z]/', $nm)) { $ic = strtoupper($nm[0]); }
|
||
else { $ic = mb_substr($nm, 0, 1, 'UTF-8'); }
|
||
}
|
||
}
|
||
}
|
||
if ($ic === '') { $ic = '·'; }
|
||
// 只允许极短内容,防止把整段 HTML 塞进图标位
|
||
return mb_substr($ic, 0, 4, 'UTF-8');
|
||
}
|
||
|
||
/**
|
||
* 图标 + 名字的组合标签(HTML)。
|
||
* 前端所有「这是哪个软件」的地方都用它,保证视觉一致。
|
||
*
|
||
* 🔴 不要背景色:图标本身就是一张图/一个 emoji,再垫一个色块反而糊在一起。
|
||
* 直接「图标 + 文字」并排,识别度最高(用户 2026-11-03 明确要求)。
|
||
*
|
||
* @param array $sw 软件行
|
||
* @param string $size 'sm' | 'md' | 'lg' | 'brand'(brand = 顶栏主标题)
|
||
* @param string $label 覆盖显示文字(默认=软件名)。顶栏用它传「XX 反馈系统」。
|
||
*/
|
||
function fb_software_badge($software, $size = 'md', $label = '') {
|
||
if (!is_array($software) || empty($software['name'])) { return ''; }
|
||
$icon = fb_software_icon($software);
|
||
$name = $label !== '' ? (string)$label : (string)$software['name'];
|
||
|
||
/* 尺寸档位(以「选择软件」标签的 13.5px 为基准,逐档放大):
|
||
* sm 18px 图标 / 14px 字 软件选择器里的小标签
|
||
* md 26px 图标 / 19px 字 「选择软件」抽屉里的条目
|
||
* lg 40px 图标 / 22px 字 内容区「图标 + 软件名」
|
||
* brand 32px 图标 / 17px 字 顶栏主标题
|
||
*
|
||
* 🔴 用户明确指出:顶栏标题的字号「比下面还小」是错的,但给到 26px 又太大。
|
||
* 折中标准 = 比「选择软件」那个 13.5px 标签大两号 ≈ 17px。
|
||
* 图标同比缩到 32px,别让图标压过文字。 */
|
||
$px = ['sm' => 18, 'md' => 26, 'lg' => 40, 'brand' => 32];
|
||
$fs = ['sm' => 14, 'md' => 19, 'lg' => 22, 'brand' => 17];
|
||
$box = isset($px[$size]) ? $px[$size] : 26;
|
||
$fnt = isset($fs[$size]) ? $fs[$size] : 19;
|
||
|
||
/* 有上传的图标图片就用图片(管理员自己传的 logo),没有才用字符。
|
||
* 两者外框尺寸一致,换用图片不会让布局跳动。 */
|
||
$imgKey = isset($software['icon_key']) ? trim((string)$software['icon_key']) : '';
|
||
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, $box * 2) : '';
|
||
if ($imgUrl !== '') {
|
||
return '<span class="sw-badge sw-badge-' . e($size) . '">'
|
||
. '<img class="sw-badge-img" src="' . e($imgUrl) . '" alt=""'
|
||
. ' width="' . $box . '" height="' . $box . '" loading="lazy"'
|
||
. ' style="width:' . $box . 'px;height:' . $box . 'px">'
|
||
. '<span class="sw-badge-nm">' . e($name) . '</span>'
|
||
. '</span>';
|
||
}
|
||
|
||
return '<span class="sw-badge sw-badge-' . e($size) . '">'
|
||
. '<span class="sw-badge-ic" aria-hidden="true" style="font-size:' . $fnt . 'px">'
|
||
. e($icon) . '</span>'
|
||
. '<span class="sw-badge-nm">' . e($name) . '</span>'
|
||
. '</span>';
|
||
}
|
||
|
||
/**
|
||
* 「XX 反馈系统」—— 当前软件下的站点标题。
|
||
* 多软件共用一个反馈站时,标题必须点明是哪个软件的反馈系统,
|
||
* 用户才知道自己没走错地方。没传软件时退回站点名。
|
||
*
|
||
* ⚠️ 软件名本身已含「反馈」二字时不再叠加,避免出现「轻反馈反馈系统」。
|
||
*/
|
||
function fb_software_site_title($software) {
|
||
if (!is_array($software) || empty($software['name'])) {
|
||
return (string)fb_cfg('site_name', '轻反馈');
|
||
}
|
||
$name = trim((string)$software['name']);
|
||
if ($name === '') { return (string)fb_cfg('site_name', '轻反馈'); }
|
||
if (mb_strpos($name, '反馈', 0, 'UTF-8') !== false) { return $name; }
|
||
return $name . '反馈系统';
|
||
}
|
||
|
||
/** 某软件下的分类;不传软件则返回空数组 */
|
||
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);
|
||
}
|
||
|
||
/** 频率限制 */
|
||
/**
|
||
* 频率检查。
|
||
* @param int $limit 每小时的允许次数;**0 或负数表示不限制**
|
||
*
|
||
* ⚠️ 曾经写成 `count < limit` —— limit=0 时 `0 < 0` 为假,
|
||
* 用户把「每小时上传次数」设成 0(本意是不限制)会变成「一次都不许传」。
|
||
* 语义正好相反,属于"填 0 反而全禁掉"的陷阱。
|
||
*/
|
||
function fb_rate_check($action, $limit) {
|
||
$limit = (int)$limit;
|
||
if ($limit <= 0) { return true; } // 0 = 不限制,直接放行
|
||
$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()) < $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',
|
||
'trusted_proxies',
|
||
'upload.image_on', 'upload.video_on',
|
||
'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',
|
||
'notify.receipt_enabled',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 全部设置(扁平:'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) { /* 日志失败不影响主流程 */ }
|
||
}
|