Files
SimpleNav/functions.php
T
laoyang 273074218c chore: 清除仓库中的开发者个人信息
面向公开仓库做一次脱敏,去掉与开发者个人绑定的域名、内网入口与环境路径。

- db.php:默认数据里的个人地址全部换成通用示例(原先是开发者的私有基础设施:
  带路径令牌的面板地址、若干内网服务入口,以及暴露学校身份的校内系统如教务 / BB / VPN / 财务)。
  分组结构(2 个网格板块 + 3 个合集板块)与条目数量保持不变,新装仍是一份可用的示例导航。
- README.md:去掉「复刻某站风格」的表述与示例路径里的个人域名;部署步骤改为通用写法
  (不再指向仓库外的私有脚本目录),并补上 Nginx 的 /data/ 拦截规则。
  顺带修正一处与实际不符的描述:原文档称「页面缓存插件无需任何特殊配置」,实测该插件读不到
  FastCGI 的 Cache-Control,实际是靠根 .htaccess 在请求侧注入 X_CACHE=1 才旁路掉的。
- .htaccess / config.php / functions.php / assets/css/style.css:注释里的个人域名改为通用表述。
- .gitignore:去掉开发者本机目录约定。
- LICENSE:版权署名由个人昵称改为 The SimpleNav Authors。

默认数据只在空库时写入,线上现有数据不受影响;本次无运行期行为变更。

验证:本地 php -l 全过;e2e 93/93;部署(双版本 php -l + 静态断言 + 文件哈希逐字节一致 +
HTTP 冒烟 + 缓存旁路自检 + Web 身份诊断)全绿,退出码 0。
2026-09-28 18:34:44 +08:00

729 lines
26 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
/**
* 简航 SimpleNav —— 公共函数
*/
declare(strict_types=1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
/** HTML 转义 */
function e(?string $s): string
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** 规范化 URL:补协议、去空白 */
function norm_url(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^https?://#i', $url)) {
$url = 'http://' . $url;
}
return $url;
}
/**
* 取主域名(用于首字母回退、标题回退等展示用途)。
*
* ⚠️ 不要再拿它当图标文件名!图标文件请一律用 icon_key($url) ——
* 同一域名下不同路径是两个不同的网站条目,用域名做键会互相覆盖。
*/
function domain_of(string $url): string
{
$host = parse_url(norm_url($url), PHP_URL_HOST);
return $host ? preg_replace('/^www\./i', '', $host) : '';
}
/** curl 抓取(带 UA、超时、跟随跳转) */
function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
if (!function_exists('curl_init')) {
// 兜底:allow_url_fopen
$ctx = stream_context_create(['http' => [
'timeout' => $timeout,
'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n",
'follow_location' => 1,
]]);
$body = @file_get_contents($url, false, $ctx, 0, $maxBytes);
if ($body === false) {
return null;
}
return ['body' => $body, 'url' => $url, 'type' => ''];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'],
]);
$body = curl_exec($ch);
$type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_errno($ch);
curl_close($ch);
if ($err || $body === false || $code >= 400) {
return null;
}
return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type];
}
/* =========================================================================
* 元信息抓取:标题 / 名称 / 图标候选
* ========================================================================= */
/**
* 抓取网站元信息。
* icons 为「按清晰度从高到低」排好的候选图标地址列表:
* manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
function fetch_site_meta(string $url): array
{
$url = norm_url($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
$resp = http_get($url);
if ($resp === null) {
$out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
$html = ensure_utf8((string)$resp['body']);
$final = $resp['url'] !== '' ? $resp['url'] : $url;
$out['final_url'] = $final;
// ---- 标题 / 名称 ----
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
}
$out['name'] = meta_content($html, 'og:site_name');
if ($out['name'] === '' && $out['title'] !== '') {
// 用标题的首段(按分隔符切分)作为名称
$seg = preg_split('#[\|\-_—·::]#u', $out['title']);
$out['name'] = trim($seg[0] ?? '');
}
// ---- 图标候选(按清晰度从高到低)----
// 1. manifest 里的大图标(常见 512/192)
// 2. apple-touch-icon(常见 180)
// 3. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
// 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
// 5. 兜底:站点自身 /favicon.ico
[$links, $manifestUrl] = parse_icon_links($html, $final);
$icons = [];
if ($manifestUrl !== '') {
foreach (manifest_icons($manifestUrl) as $u) {
$icons[] = $u;
}
}
foreach ($links as $u) {
$icons[] = $u;
}
foreach (third_party_icon_urls($final) as $u) {
$icons[] = $u;
}
foreach (default_icon_candidates($final) as $u) {
$icons[] = $u;
}
$out['icons'] = array_values(array_unique(array_filter($icons, static function ($u) {
return is_string($u) && preg_match('#^https?://#i', $u);
})));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
/**
* 联网搜索高清图标的第三方聚合服务。
* 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG);
* 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。
* 可在后台「系统设置」里用 icon_services 关闭。
*/
function third_party_icon_urls(string $url): array
{
if (setting('icon_services', '1') !== '1') {
return [];
}
$host = parse_url(norm_url($url), PHP_URL_HOST);
if (!$host) {
return [];
}
$host = strtolower($host);
return [
'https://favicon.yandex.net/favicon/' . $host . '?size=120', // 实测 100% 可用,120×120
'https://icon.horse/icon/' . $host, // 可达时最高 256×256,偶有超时
];
}
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
function meta_content(string $html, string $key): string
{
if (!preg_match_all('#<meta\b[^>]*>#i', $html, $tags)) {
return '';
}
$k = preg_quote($key, '#');
foreach ($tags[0] as $t) {
if (!preg_match('#\b(?:property|name|itemprop)\s*=\s*(?:"' . $k . '"|\'' . $k . '\'|' . $k . '[\s/>])#i', $t)) {
continue;
}
if (preg_match('#\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $t, $cm)) {
$v = $cm[1] !== '' ? $cm[1] : ($cm[2] !== '' ? $cm[2] : ($cm[3] ?? ''));
return trim(html_entity_decode($v, ENT_QUOTES, 'UTF-8'));
}
}
return '';
}
/**
* 解析页面里的 <link> 图标声明。
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
*/
function parse_icon_links(string $html, string $base): array
{
$apple = [];
$rel = [];
$manifest = '';
if (preg_match_all('#<link\b[^>]*>#i', $html, $tags)) {
foreach ($tags[0] as $tag) {
$relv = attr_value($tag, 'rel');
if ($relv === '') {
continue;
}
$relv = strtolower($relv);
$href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
if ($href === '') {
continue;
}
if (strpos($relv, 'apple-touch-icon') !== false) {
$apple[] = $href;
continue;
}
if (strpos($relv, 'manifest') !== false) {
if ($manifest === '') { $manifest = $href; }
continue;
}
if (strpos($relv, 'icon') === false) {
continue;
}
$size = 0;
$raw = attr_value($tag, 'sizes');
if ($raw !== '') {
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) {
if (stripos($one, 'any') !== false) {
$size = max($size, 256);
} elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) {
$size = max($size, (int)$gm[1], (int)$gm[2]);
}
}
}
// SVG 是矢量,视为最高清
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) {
$size = max($size, 512);
}
$rel[] = ['size' => $size, 'url' => $href];
}
}
usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; });
$list = [];
foreach ($apple as $h) { $list[] = absolutize($h, $base); }
foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); }
return [$list, $manifest === '' ? '' : absolutize($manifest, $base)];
}
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
function manifest_icons(string $manifestUrl): array
{
$r = http_get($manifestUrl, 200000, 6);
if ($r === null) {
return [];
}
$json = json_decode(trim((string)$r['body']), true);
if (!is_array($json) || empty($json['icons']) || !is_array($json['icons'])) {
return [];
}
$base = $r['url'] !== '' ? $r['url'] : $manifestUrl;
$list = [];
foreach ($json['icons'] as $ic) {
if (empty($ic['src']) || !is_string($ic['src'])) {
continue;
}
$size = 0;
if (!empty($ic['sizes'])) {
foreach (preg_split('/[\s,]+/', (string)$ic['sizes']) ?: [] as $one) {
if (stripos($one, 'any') !== false) {
$size = max($size, 512);
} elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) {
$size = max($size, (int)$gm[1], (int)$gm[2]);
}
}
}
$list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)];
}
usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; });
return array_values(array_unique(array_column($list, 'url')));
}
/** 兜底候选:站点自身 favicon.ico */
function default_icon_candidates(string $url): array
{
$p = parse_url(norm_url($url));
if (empty($p['host'])) {
return [];
}
$scheme = !empty($p['scheme']) ? $p['scheme'] : 'https';
return [$scheme . '://' . $p['host'] . '/favicon.ico'];
}
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
function attr_value(string $tag, string $attr): string
{
$a = preg_quote($attr, '#');
if (preg_match('#\b' . $a . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) {
$v = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? ''));
return trim($v);
}
return '';
}
/* =========================================================================
* 图标下载与规范化
* ========================================================================= */
/**
* 图标文件的命名键:**按整条网址**算,而不是只按域名。
*
* 为什么不能只用域名(2026-09-28 修的真实 bug):
* 同一域名下不同路径往往是两个独立的网站条目,例如
* https://nas.example.com/calendar 与 https://nas.example.com/
* 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标,
* 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。
*
* 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异):
* - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目)
* - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个)
* - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息
* 一句话:**不同网址 → 不同图标文件**。
*
* 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url,
* 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。
*
* 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定,
* 不影响任何行为)。
*/
function icon_key(string $url): string
{
$u = trim($url);
if ($u === '') {
return '';
}
if (!preg_match('#^https?://#i', $u)) {
$u = 'http://' . $u;
}
$p = @parse_url($u);
if (!is_array($p) || empty($p['host'])) {
return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞
}
$norm = strtolower((string)$p['scheme']) . '://'
. strtolower((string)$p['host'])
. (empty($p['port']) ? '' : ':' . $p['port'])
. (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/')
. (isset($p['query']) ? '?' . $p['query'] : '')
. (isset($p['fragment']) ? '#' . $p['fragment'] : '');
return md5($norm);
}
/**
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
*
* @param string[] $urls 候选图标地址(已按优先级排序)
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string
{
if ($key === '') { // 没有键就不写文件,避免全部落到 md5('') 上互相覆盖
return '';
}
if (!is_dir(ICON_PATH)) {
@mkdir(ICON_PATH, 0755, true);
}
$best = null;
$bestScore = -1;
$tmps = [];
$tried = 0;
$deadline = microtime(true) + $budget;
foreach ($urls as $u) {
if (!is_string($u) || $u === '' || !preg_match('#^https?://#i', $u)) {
continue;
}
if ($tried >= 8 || microtime(true) > $deadline) { // 候选上限 + 耗时预算
break;
}
$tried++;
$r = grab_icon($u);
if ($r === null) {
continue;
}
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) {
@unlink($r['tmp']);
continue;
}
$tmps[] = $r['tmp'];
$score = min($r['w'], $r['h']);
if ($score > $bestScore) {
$bestScore = $score;
$best = $r;
}
if ($score >= $minGood) {
break;
}
}
// 清理落选临时文件
foreach ($tmps as $t) {
if (!$best || $t !== $best['tmp']) {
@unlink($t);
}
}
if ($best === null) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.' . $best['ext'];
$dest = ICON_PATH . '/' . $file;
@unlink(ICON_PATH . '/' . $md5 . '.png');
@unlink(ICON_PATH . '/' . $md5 . '.svg');
if (!@copy($best['tmp'], $dest)) {
@unlink($best['tmp']);
return '';
}
@unlink($best['tmp']);
return is_file($dest) ? $file : '';
}
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
function is_third_party_icon(string $url): bool
{
$host = strtolower((string)parse_url($url, PHP_URL_HOST));
return $host === 'favicon.yandex.net' || $host === 'icon.horse';
}
/**
* 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。
* 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面,
* 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。
*/
function looks_generated_tile(string $file): bool
{
$im = @imagecreatefromstring((string)@file_get_contents($file));
if ($im === false) {
return false;
}
$w = imagesx($im);
$h = imagesy($im);
$sx = max(1, (int)($w / 28));
$sy = max(1, (int)($h / 28));
$maxChroma = 0;
$n = 0;
$light = 0;
$sum = 0.0;
for ($y = 0; $y < $h; $y += $sy) {
for ($x = 0; $x < $w; $x += $sx) {
$rgba = imagecolorat($im, $x, $y);
if ((($rgba >> 24) & 0x7F) > 110) { // 透明像素跳过
continue;
}
$r = ($rgba >> 16) & 0xFF;
$g = ($rgba >> 8) & 0xFF;
$b = $rgba & 0xFF;
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($chroma > $maxChroma) { $maxChroma = $chroma; }
$v = ($r + $g + $b) / 3;
$sum += $v;
if ($v >= 195 && $chroma < 16) { $light++; }
$n++;
}
}
imagedestroy($im);
if ($n < 24) {
return false;
}
return $maxChroma < 16 && ($sum / $n) > 190 && ($light / $n) >= 0.80;
}
/**
* 下载单个图标到临时文件。
* @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优
*/
function grab_icon(string $iconUrl): ?array
{
$resp = http_get($iconUrl, 2 * 1024 * 1024, 5);
if ($resp === null) {
return null;
}
$bin = (string)$resp['body'];
if (strlen($bin) < 64 || strlen($bin) > 3 * 1024 * 1024) {
return null;
}
$tmp = @tempnam(sys_get_temp_dir(), 'snic');
if ($tmp === false) {
return null;
}
// ---- SVG:直接保存(矢量最清晰),挡掉带脚本的 ----
$head = ltrim(substr($bin, 0, 1024));
$isSvg = stripos((string)$resp['type'], 'svg') !== false
|| stripos($head, '<svg') !== false
|| strpos($head, '<?xml') === 0;
if ($isSvg) {
if (strlen($bin) > 512 * 1024
|| preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $bin)) {
@unlink($tmp);
return null;
}
if (@file_put_contents($tmp, $bin) === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'ext' => 'svg', 'w' => 512, 'h' => 512];
}
// ---- 位图:GD 归一化为 128×128 PNG(保持比例、透明留白) ----
if (!function_exists('imagecreatefromstring')) {
@unlink($tmp);
return null;
}
$im = @imagecreatefromstring($bin);
if ($im === false) {
@unlink($tmp);
return null;
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) { // 太小/损坏,判为无效
imagedestroy($im);
@unlink($tmp);
return null;
}
$store = ICON_STORE;
$canvas = imagecreatetruecolor($store, $store);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
$scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
imagecopyresampled(
$canvas, $im,
(int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0,
$nw, $nh, $w, $h
);
imagealphablending($canvas, true);
$ok = imagepng($canvas, $tmp, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'ext' => 'png', 'w' => $w, 'h' => $h];
}
/**
* 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。
*
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
function save_custom_icon(string $binary, string $key): string
{
if ($key === '') {
return '';
}
if (!is_dir(ICON_PATH)) {
@mkdir(ICON_PATH, 0755, true);
}
$mime = '';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_buffer($fi, $binary);
finfo_close($fi);
}
if (strpos($mime, 'svg') !== false || stripos(ltrim(substr($binary, 0, 1024)), '<svg') !== false) {
if (preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $binary)) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.svg';
@unlink(ICON_PATH . '/' . $md5 . '.png');
@file_put_contents(ICON_PATH . '/' . $file, $binary);
return is_file(ICON_PATH . '/' . $file) ? $file : '';
}
if (!function_exists('imagecreatefromstring')) {
return '';
}
$im = @imagecreatefromstring($binary);
if ($im === false) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) {
imagedestroy($im);
return '';
}
$store = ICON_STORE;
$canvas = imagecreatetruecolor($store, $store);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
$scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
imagecopyresampled($canvas, $im, (int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0, $nw, $nh, $w, $h);
imagealphablending($canvas, true);
$md5 = md5($key);
$file = $md5 . '.png';
$ok = imagepng($canvas, ICON_PATH . '/' . $file, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
return '';
}
@unlink(ICON_PATH . '/' . $md5 . '.svg');
return $file;
}
/* =========================================================================
* 杂项
* ========================================================================= */
/** 把可能为相对路径的 URL 补全为绝对 URL */
function absolutize(string $href, string $base): string
{
// 有些页面把链接写成 JSON 转义形式(https:\/\/x\/y.ico),先还原
$href = str_replace('\\/', '/', trim($href));
if (preg_match('#^https?://#i', $href)) {
return $href;
}
$p = parse_url($base);
$root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? '');
if (strpos($href, '//') === 0) {
return ($p['scheme'] ?? 'http') . ':' . $href;
}
if (strpos($href, '/') === 0) {
return $root . $href;
}
$dir = rtrim(dirname($p['path'] ?? '/'), '/');
return $root . $dir . '/' . $href;
}
/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
function ensure_utf8(string $s): string
{
if (preg_match('#<meta[^>]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) {
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
}
if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
return $s;
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
function icon_src(string $file): string
{
static $cache = [];
if ($file === '') {
return '';
}
if (!isset($cache[$file])) {
$abs = ICON_PATH . '/' . $file;
$cache[$file] = is_file($abs) ? (string)filemtime($abs) : '0';
}
// 带版本号:图标被重新抓取后文件名不变,没版本号浏览器会一直用旧图
return url(ICON_URL . '/' . $file) . '?v=' . $cache[$file];
}
/**
* 动态页面统一发「不要缓存」响应头。
*
* 为什么需要 —— 2026-09-28 实测搞清楚的两层缓存:
*
* ① 浏览器缓存:本站在此之前完全没有发 Cache-Control / Expires / ETag /
* Last-Modified,浏览器只能自己「猜」缓存策略,于是后台改了设置、前台
* 刷新却还是旧页面。上面那几个头就是治它的(这层已彻底解决)。
*
* ② 服务器侧页面缓存:宝塔面板的「网站加速」**Apache(mod_lua) 版**
* (脚本 /www/server/speed/httpd_speed.lua,后端 memcached 127.0.0.1:11211,
* 站点配置 /www/server/speed/config.lua 里该站点那一节,expire=30 秒)。
* 它的规则(读源码 + 线上实测):只缓存 GET + 200 + Content-Type 含 html + ≤1MB;
* 图片/CSS/JS 等扩展名直接跳过;缓存键 = host:port + 完整 URI(含 query) + _pc|_mobile。
* ⚠️ 它本意是「响应头 Cache-Control 含 no-cache 就不写入」,**但实测读不到
* FastCGI 后端返回的 Cache-Control**(存下来的副本里根本没这个头),所以光发
* no-store 挡不住它 —— 用 curl 连打同一 URL 会 HIT,HIT,HIT…,隔 30 秒才 MISS。
* 它唯一认的信号是**请求** cookie `X_CACHE=1`(本来是给登录用户打的标记)。
* → 根治靠根目录 .htaccess 里的 `RequestHeader` 在请求侧注入该标记(已实测:
* 任何访客都是 X-Cache: MISS,彻底不吃那 30 秒缓存)。
* → 下面这颗 cookie 是**第二道保险**:在 Nginx 等不读 .htaccess 的环境里顶上。
* 由 config.php 的 BYPASS_PAGE_CACHE 控制(false 则只发头、接受 ≤30 秒窗口)。
*/
function send_no_cache(): void
{
if (headers_sent()) {
return;
}
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
// 绕开服务器侧整页缓存(详见本函数顶部注释 ②)
if (defined('BYPASS_PAGE_CACHE') && BYPASS_PAGE_CACHE && (string)($_COOKIE['X_CACHE'] ?? '') !== '1') {
setcookie('X_CACHE', '1', [
'expires' => time() + 31536000,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
}
}
/** 资源版本号(mtime + 文件大小),避免浏览器缓存旧文件 */
function asset(string $relPath): string
{
$abs = APP_PATH . '/' . ltrim($relPath, '/');
$v = is_file($abs) ? (string)filemtime($abs) . '-' . (string)filesize($abs) : '0';
return url($relPath) . '?v=' . $v;
}
/** 名称首字配色(无图标时的回退头像底色) */
function letter_color(string $s): string
{
$palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
return $palette[abs(crc32($s)) % count($palette)];
}