Files
FeedLite/lib/util.php
T
laoyang a00e2221c4 调色板从 8 色扩到 12 色(按色相环重排);后台预设改为共用同一份
用户:「不同的软件要有不同的颜色,现在颜色有点少,快不够分了,
      至少应该有 10 个左右的颜色。」

调研发现的问题比「数量少」更严重:
🔴 调色板有两份独立硬编码 —— util.php 一份、admin_taxonomy.php 又一份,
   两边的色值还不一样,早就不同步了。用户在后台只能从 8 个里选,
   而实际分配用的却是另一批色值。

改法:
- 新增 fb_color_palette() 作为唯一出口,12 色
- fb_color_default_for() 改为调用它
- 后台  也改为调用它 —— 从此只有一处需要维护

12 色设计(360° 切 12 段,逐色验证,不是随手挑的):
  色相:2/32/62/92/122/152/178/200/232/266/293/330,**最小相邻间距 22°**
  对比度:白底全部落在 4.76~6.19,既都够看清(WCAG AA 需 ≥4.5),
          又不会某个颜色特别深显得突兀
  压制项:黄/黄绿饱和 50%(再高就荧光)、蓝/靛蓝压到 62%/50%
  避开项:纯红纯绿会与「待处理/已解决」状态色撞,所以红取 2°、绿取 122°

中途踩过的坑(记录下来避免重犯):
  第一版直接往原 8 色后面追加 6 个,实测发现 8 对颜色几乎同色 ——
  因为原调色板里 185°/212°/217° 三个蓝本就挤在一起。
  「塞进缝隙」这个思路是错的,必须重排整个色相环。

现有 5 个软件颜色**未改动**(实测仍是 #0057b8/#b4553c/#6a4c93/#0e7c86/#3a5a8c):
它们的色相分布本就合理(2°12°185°212°265°),没必要让已发布链接的配色突变。
新调色板只影响「新添加且未指定颜色」的软件。

验证:12 色无重复、对比度全 ≥4.5、最小色相间距 22°、
后台已改为调用 fb_color_palette() 且旧硬编码数组已消失、6 个路由 200。
2026-11-04 17:54:39 +08:00

1077 lines
42 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。
*
* 🔴🔴 这里曾经是**无条件信任** 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];
}
/**
* 给某个软件挑一个默认主题色(未配置时用)。
* 按软件的 id 从调色板里取,保证同一站点里不同软件颜色不撞。
*
* 🔴 2026-11-04 按色相环**整体重排**为 12 色(用户:颜色快不够分了)。
*
* 选色规则,改之前先读:
* ① **色相均匀分布**:360° 切成 12 段,相邻至少差 22°(实测最小 22°)。
* 旧调色板有 3 个蓝挤在 185°/212°/217°,并排看几乎同色 —— 这是重排的原因;
* ② **对比度落在 4.76~6.19 的窄带**:白底上文字都够看清(WCAG AA 要 ≥4.5),
* 又不会某个颜色特别深、显得比其他刺眼;
* ③ **避开纯红/纯绿**:会与「待处理/已解决」状态色打架,所以红取 2°、绿取 122°;
* ④ 黄/黄绿饱和度压到 50%(再高就荧光),蓝/靛蓝也压到 62%/50%。
*
* ⚠️ 这个数组和 lib/admin_taxonomy.php 里的 $presets **必须保持一致**。
* 以前是两份独立硬编码,早就不同步了 —— 后台只给 8 个可选色,
* 实际调色板却是另一批,用户在后台根本选不到新增颜色。
* 改这里记得同步改那边(见 fb_color_palette() 统一出口)。
*/
function fb_color_palette() {
return [
'#B53530', // 1 红 2°
'#9D6525', // 2 橙 32°
'#747728', // 3 黄 62°
'#517E2A', // 4 黄绿 92°
'#2B822E', // 5 绿 122°
'#2D8059', // 6 青绿 152°
'#267E7B', // 7 青 178°
'#2678A1', // 8 天蓝 200°
'#4154D2', // 9 蓝 232°
'#7A44C1', // 10 靛蓝 266°
'#943BA0', // 11 紫 293°
'#AD3470', // 12 玫红 330°
];
}
function fb_color_default_for($softwareId) {
$palette = fb_color_palette();
$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 24px 图标 / 16px 字 抽屉里的条目
* lg 24px 图标 / 15px 字 内容区「图标 + 软件名」
* brand 30px 图标 / 17px 字 顶栏主标题
*
* 🔴 两条硬约束(都踩过):
* 1. 顶栏标题 = 比「选择软件」的 13.5px 大两号 ≈ 17px,不要再放大到 26px;
* 2. **顶栏必须比内容区大**。之前顶栏 26px、内容区 30px,
* 主次直接颠倒,看着就是「上面的字比下面的小」。
* 现在 17 > 15,层级正确;内容区只是标识,不该抢标题的戏。 */
$px = ['sm' => 18, 'md' => 24, 'lg' => 24, 'brand' => 30];
$fs = ['sm' => 14, 'md' => 16, 'lg' => 15, 'brand' => 17];
$box = isset($px[$size]) ? $px[$size] : 24;
$fnt = isset($fs[$size]) ? $fs[$size] : 16;
/* 有上传的图标图片就用图片(管理员自己传的 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 . '反馈系统';
}
/**
* 软件徽标(后台/前台的小标签)—— 带图标,且用该软件自己的主题色。
*
* 为什么不复用 .badge 的固定蓝色:每个软件都配了主题色,
* 徽标却一律蓝色,等于把「颜色区分软件」这条设计浪费掉了。
*
* @param array $sw 软件行
* @param bool $icon 是否显示图标(默认显示)
*/
function fb_software_chip($sw, $withIcon = true) {
if (!is_array($sw) || empty($sw['name'])) { return ''; }
$name = (string)$sw['name'];
list($c) = fb_software_colors($sw);
if ($c === '') { $c = '#0057B8'; }
$soft = fb_color_soft($c); // 同色系浅底
$line = fb_color_line($c); // 同色系描边
$icHtml = '';
if ($withIcon) {
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 30) : '';
if ($imgUrl !== '') {
$icHtml = '<span class="badge-sw-ic"><img src="' . e($imgUrl) . '" alt=""></span>';
} else {
$icHtml = '<span class="badge-sw-ic" aria-hidden="true">' . e(fb_software_icon($sw)) . '</span>';
}
}
return '<span class="badge sw"'
. ' style="--swc:' . e($c) . ';--swc-soft:' . e($soft) . ';--swc-line:' . e($line) . '">'
. $icHtml
. '<span class="badge-sw-nm">' . e($name) . '</span>'
. '</span>';
}
/** 把主色调淡成浅底(与主色同色系,避免灰化) */
function fb_color_soft($hex) {
$rgb = fb_hex_to_rgb($hex);
if (!$rgb) { return '#eef2f8'; }
return sprintf('#%02x%02x%02x',
(int)round($rgb[0] + (255 - $rgb[0]) * 0.90),
(int)round($rgb[1] + (255 - $rgb[1]) * 0.90),
(int)round($rgb[2] + (255 - $rgb[2]) * 0.90));
}
/** 把主色调成描边色(略深于浅底) */
function fb_color_line($hex) {
$rgb = fb_hex_to_rgb($hex);
if (!$rgb) { return '#cfdcee'; }
return sprintf('#%02x%02x%02x',
(int)round($rgb[0] + (255 - $rgb[0]) * 0.72),
(int)round($rgb[1] + (255 - $rgb[1]) * 0.72),
(int)round($rgb[2] + (255 - $rgb[2]) * 0.72));
}
/** #rrggbb -> [r,g,b],非法返回 null */
function fb_hex_to_rgb($hex) {
$h = ltrim((string)$hex, '#');
// 三位简写只接受纯十六进制字符(#abc -> #aabbcc);
// 先做字符校验再展开,否则 "bad" 这种非色值会被当成 #bbaadd
if (strlen($h) === 3 && preg_match('/^[0-9a-fA-F]{3}$/', $h)) {
$h = $h[0].$h[0].$h[1].$h[1].$h[2].$h[2];
}
if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) { return null; }
return [hexdec(substr($h, 0, 2)), hexdec(substr($h, 2, 2)), hexdec(substr($h, 4, 2))];
}
/** 某软件下的分类;不传软件则返回空数组 */
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 就能拿到名字(列表渲染用)
*
* ⚠️ 参数必须是**数字 id**。传字符串 slug(如 'bug')会被 (int) 成 0 而返回空串,
* 调用方如果不判空就会渲染出一个空标签 —— 这个坑踩过(见 admin.php 全部问题列表)。
* 需要按 slug 取名字请用 fb_category_label_by_slug()。 */
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];
}
/**
* 按「软件 + slug」取分类显示名 —— 给只存了老 slug 字段的历史数据兜底。
* slug 在不同软件下可能重名,所以必须带上 software_id 才能定位。
*/
function fb_category_label_by_slug($softwareId, $slug) {
$slug = trim((string)$slug);
if ($slug === '') { return ''; }
if ((int)$softwareId > 0) {
$c = fb_category_by_slug((int)$softwareId, $slug);
if ($c) { return (string)$c['name']; }
}
// 实在定不到软件(老数据 software_id 为空)时,按 slug 全局找一个
$st = fb_db()->prepare('SELECT name FROM categories WHERE slug = ? LIMIT 1');
$st->execute([$slug]);
$nm = $st->fetchColumn();
return $nm !== false ? (string)$nm : '';
}
/** 全部状态(按排序) */
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();
}
/**
* 生成一个新的追踪 token(提交反馈时调用)。
* 32 位 hex = 128 位熵,猜不出来;这个值只存在自己库里、只发给提问人邮箱。
*/
function fb_track_new_token() {
return bin2hex(random_bytes(16));
}
/** 这个请求带来的 token 是否与帖子的匹配(常数时间比较,防时序探测) */
function fb_track_token_ok(array $thread, $given) {
$given = trim((string)$given);
if ($given === '') { return false; }
$real = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
if ($real === '') { return false; }
return hash_equals($real, $given);
}
/**
* 当前访问者是否「有权跟踪」这条帖子 —— 即带对了 token。
* 审核后台的管理员也算有权(不然审核时看不了)。
*/
function fb_thread_trackable(array $thread, $given = null) {
if ($given === null) { $given = isset($_GET['t']) ? (string)$_GET['t'] : ''; }
if (fb_track_token_ok($thread, $given)) { return true; }
return fb_is_admin();
}
/**
* 当前访问者能否【查看】这条帖子。
*
* 公开的(visible=1)所有人可看;
* 未公开的只有两种人能看:审核后台管理员、拿着正确 token 的提交人。
* 这就是「不登录也能跟踪自己那条」的实现方式,不靠任何登录态。
*/
function fb_thread_viewable(array $thread, $given = null) {
if (fb_thread_public($thread)) { return true; }
return fb_thread_trackable($thread, $given);
}
/**
* 回「某个软件的问题列表」的链接。
*
* 首页现在是介绍页,不带 sw 的回首页等于把用户扔回介绍页 ——
* 从详情/提交页点「返回问题列表」应该回到**当前软件**的列表。
*/
function fb_software_list_url($sw) {
if (!is_array($sw) || empty($sw['slug'])) { return 'index.php'; }
return 'index.php?sw=' . urlencode((string)$sw['slug']);
}
/**
* 这条帖子的对外链接。
*
* 🔴 用 token 不用 id:
* - 邮件里发给提问人的链接一律带 ?t=<token>,他才能看未公开的那条;
* - 公开列表里给所有人看的链接照旧用 id(公开内容本来就谁都能看)。
* 早期所有链接都是 thread.php?id=N,导致「未审核就点不进去」——
* id 是自增的,用它当权限等于没有权限。
*
* @param array $thread 帖子行
* @param bool $personal true = 带 token 的私人链接;false = 公开链接
*/
function fb_thread_link(array $thread, $personal = false) {
$id = (int)(isset($thread['id']) ? $thread['id'] : 0);
$url = fb_base_url() . '/thread.php?id=' . $id;
if ($personal) {
$tok = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
if ($tok !== '') { $url .= '&t=' . rawurlencode($tok); }
}
return $url;
}
/* ---------------- 安装状态 ---------------- */
/** 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) { /* 日志失败不影响主流程 */ }
}