Files
laoyang ae75058de1 未安装时自动跳转安装向导(修「打开首页不跳转」)
现象:全新部署后打开域名,看到的是一个空导航站(没有任何提示),
必须自己猜 /install.php 这个网址才能安装。

原因:index.php 一进来就 db() 建库并渲染前台,全站没有任何
「还没装」的引导逻辑;install.php 只能靠手输网址触达。

改动:
- functions.php 新增 app_installed():全站唯一的「已安装」判断源
  (有管理员账号 = 已安装),内部 try/catch 兜住建库失败
- index.php 开头:未安装且 install.php 存在 → 302 到安装页
  · 只在 install.php 真存在时跳,用户装完删掉后自动失效,不会 404
- install.php 复用 app_installed(),删掉自己那套 is_file(DB_PATH) 推断,
  避免「首页说没装、install 说装了」的口径分裂

测试(都在 _tooling/):
- _sn_install_redirect_e2e.py 19 项:未安装跳转 / 已安装不跳 /
  install.php 删了不跳 / 已装拒重装,四态全覆盖
- _sn_install_redirect_falsify.py 6 变体全精确命中(含「无条件跳转→死循环」)
- _sn_install_e2e.py 41 项回归全绿(顺手修 6 条因新逻辑过时的断言)
2026-11-03 22:07:22 +08:00

1133 lines
41 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 简航 SimpleNav —— 公共函数
*/
declare(strict_types=1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
/**
* 是否已完成安装(= 至少存在一个管理员账号)。
*
* **这是全站唯一的「已安装」判断源**,前台跳转、install.php 守卫、登录页文案都走这里。
* 不要各处自己写 `is_file(DB_PATH)` 或 `SELECT COUNT(*) FROM users` —— 判断口径不一会出现
* 「首页说装了、install 说没装」这类互相矛盾的状态。
*
* 为什么要 try/catch:库还建不出来(data/ 不可写、SQLite 扩展缺失)时,
* db() 会抛异常。此时应视为「未安装」,让用户走到 install.php 看到环境检查的报错,
* 而不是在前台白屏或 500。
*/
function app_installed(): bool
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
try {
$cached = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
} catch (Throwable $e) {
$cached = false;
}
return $cached;
}
/** 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;
}
/* =========================================================================
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
*
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
* ========================================================================= */
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
function site_icon_key(): string
{
$k = setting('site_icon', '');
return preg_match('/^[a-f0-9]{16,64}$/', $k) ? $k : '';
}
/** 自定义站点图标的磁盘目录(已做白名单,键只会是 hex) */
function site_icon_dir(): string
{
$k = site_icon_key();
// ⚠️ 用 rtrim 去掉尾部斜杠再拼:否则会得到 ".../uploads/site//<键>",
// 检查文件存在时 PHP 会按原样拼路径并告警/找不到。
return $k === '' ? '' : rtrim(SITE_ICON_PATH, '/\\') . '/' . $k;
}
/**
* 站点图标地址(**单一来源**)。
*
* 前台品牌区、各页 favicon、PWA manifest 全部走这里,任何一处都不许写死 ——
* 这正是过去「半生效」(只换标签页图标、主屏图标还是旧的)的来源。
*
* @param string $name 文件基名(不带扩展名),如 'logo' / 'icon-192' / 'apple-touch-icon'
* 传 '' 表示要 favicon.ico
* @param int $size 该用途的期望尺寸,用于给静态文件挑扩展名(<32 用 .ico,其余 .png)
*/
function site_icon_url(string $name = '', int $size = 32, bool $bust = true): string
{
$dir = site_icon_dir();
if ($dir === '') {
return '';
}
if ($name === '') {
$cand = [$dir . '/favicon.ico', $dir . '/icon-32.png'];
} elseif ($size < 32) {
$cand = [$dir . '/' . $name . '.ico', $dir . '/' . $name . '.png'];
} else {
$cand = [$dir . '/' . $name . '.png'];
}
foreach ($cand as $abs) {
if (is_file($abs)) {
// 静态直出:文件名带键(每次上传都换),浏览器不可能缓存到旧图;
// 再加 mtime 只为「同键被覆盖」这种手工操作兜底。
$rel = SITE_ICON_URL . '/' . site_icon_key() . '/' . basename($abs);
return url($rel) . ($bust ? '?v=' . (string)filemtime($abs) : '');
}
}
// 静态文件没落地 → 动态输出(前提是上传时已把整套序列化进 settings 的 site_icon_files)
if (!site_icon_files_ready()) {
return '';
}
if ($name === '') {
return url('site-icon.php') . ($bust ? '?v=' . site_icon_key() : '');
}
return url('site-icon.php') . '?n=' . rawurlencode($name) . ($bust ? '&v=' . site_icon_key() : '');
}
/**
* 动态回退所需的整套文件是否已备好。
*
* 只在「上传那一刻站点目录不可写」时才会有内容;目录可写的常规情况下这里是空的
* —— 刻意如此:setting() 每次请求都会把整张设置表读进内存,把 512px 的图塞进去
* 会让每个页面都白白搬运一大串 base64。
*/
function site_icon_files_ready(): bool
{
// ⚠️ 刻意**不做** static 缓存:setting() 自己已有请求内缓存,这里再套一层
// static 就会在「同一请求内先写后读」(上传成功后立刻渲染预览)时拿到旧值
// —— 与当初 setting() 那个坑同源。代价只是一次 strlen,没必要冒这个险。
$raw = setting('site_icon_files', '');
return is_string($raw) && strlen($raw) > 64;
}
/** 站点图标目录是否可写(决定走「静态优先」还是「动态回退」) */
function site_icon_dir_writable(): bool
{
$parent = SITE_ICON_PATH;
if (!is_dir($parent)) {
@mkdir($parent, 0755, true);
}
return is_dir($parent) && is_writable($parent);
}
/* ---------- 图像处理小工具(本函数只服务于上传,不用看别处) ---------- */
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
function gd_load(string $binary)
{
if (!function_exists('imagecreatefromstring')) {
return null;
}
$im = @imagecreatefromstring($binary);
return $im === false ? null : $im;
}
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
function gd_square_png($im, int $size): string
{
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$ox = (int)(($w - $side) / 2);
$oy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor($size, $size);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side);
imagealphablending($canvas, true);
ob_start();
$ok = imagepng($canvas, null, 9);
$png = (string)ob_get_clean();
imagedestroy($canvas);
return ($ok === false || $png === '') ? '' : $png;
}
/**
* 把任意图**居中裁剪成正方形**并生成整套尺寸。
*
* 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标),
* 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。
*
* @return array<string,string> 文件基名 => 二进制(基名已含扩展名,如 'logo.png')
*/
function gd_site_icon_files(string $binary): array
{
$im = gd_load($binary);
if ($im === null) {
return [];
}
if (imagesx($im) < 8 || imagesy($im) < 8) {
imagedestroy($im);
return [];
}
$out = [];
foreach (site_icon_plan() as $name => $size) {
$png = gd_square_png($im, $size);
if ($png !== '') {
$out[$name . '.png'] = $png;
}
}
// favicon.ico 里塞 16/32/48/64 四个尺寸。
// ⚠️ 别只塞 16+32:内置 favicon.ico 本来就是 4 帧(16/32/48/64),
// 只给 2 帧会让「换自定义图标」反而**退步**(高分屏/任务栏取不到合适尺寸)。
$frames = [];
foreach ([16, 32, 48, 64] as $sz) {
if (isset($out['icon-' . $sz . '.png'])) {
$frames[$sz] = $out['icon-' . $sz . '.png'];
}
}
$ico = make_ico($frames);
if ($ico !== '') {
$out['favicon.ico'] = $ico;
}
imagedestroy($im);
return $out;
}
/** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */
function make_ico(array $pngs): string
{
$frames = [];
foreach ($pngs as $png) {
$im = @imagecreatefromstring($png);
if ($im === false) {
continue;
}
$size = (int)imagesx($im);
if ($size < 1 || $size > 256 || imagesy($im) !== $size) {
imagedestroy($im);
continue;
}
// BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount
// biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant
// (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度)
$header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0);
$xor = '';
for ($y = $size - 1; $y >= 0; $y--) {
for ($x = 0; $x < $size; $x++) {
$rgba = imagecolorat($im, $x, $y);
$a = ($rgba >> 24) & 0x7F;
$xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF)
. chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787));
}
}
imagedestroy($im);
$and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见
$frames[$size] = $header . $xor . $and;
}
if (!$frames) {
return '';
}
ksort($frames);
$n = count($frames);
$ico = pack('vvv', 0, 1, $n);
$offset = 6 + $n * 16;
foreach ($frames as $size => $data) {
// 宽高字节:256 要写成 0
$b = ($size >= 256) ? 0 : $size;
$ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset);
$offset += strlen($data);
}
foreach ($frames as $data) {
$ico .= $data;
}
return $ico;
}
/** 整套图标清单:文件基名 => 尺寸 */
function site_icon_plan(): array
{
return [
'logo' => 512, // 首页品牌区(显示 60px,512 供高分屏)
'icon-16' => 16,
'icon-32' => 32,
'icon-48' => 48,
'icon-64' => 64, // 仅用于 favicon.ico 的第 4 帧(与内置资源集对齐)
'icon-192' => 192, // PWA 常规
'icon-512' => 512, // PWA 常规
'icon-192-maskable' => 192, // PWA 自适应(Android 会裁成圆形)
'icon-512-maskable' => 512,
'apple-touch-icon' => 180, // iOS 加到主屏
];
}
/**
* 保存用户上传的站点图标:生成整套尺寸、落盘并写好设置。
*
* 返回错误信息('' = 成功)。
*/
function save_site_logo(string $binary): string
{
if (!function_exists('imagecreatefromstring')) {
return '服务器缺少 GD 扩展,无法处理图片';
}
$im = gd_load($binary);
if ($im === null) {
return '图片无法解析,请换一张 PNG / JPG / WebP';
}
$w = imagesx($im);
$h = imagesy($im);
imagedestroy($im);
if ($w < 32 || $h < 32) {
return '图片太小了(' . $w . '×' . $h . '),请上传至少 32×32 的图';
}
$files = gd_site_icon_files($binary);
if (!isset($files['logo.png'])) {
return '图片处理失败,请换一张试试';
}
$key = bin2hex(random_bytes(8));
$saved = false;
if (site_icon_dir_writable()) {
$dir = SITE_ICON_PATH . '/' . $key;
@mkdir($dir, 0755, true);
foreach ($files as $fname => $data) {
@file_put_contents($dir . '/' . $fname, $data);
}
$saved = is_file($dir . '/logo.png');
}
// 目录不可写(保守环境:站点目录只读)→ 把整套压进 settings,改走动态输出。
// 这不是「可选的优化」:没有它,用户在有权限限制的服务器上上传会直接失败。
$store = '';
if (!$saved) {
$store = base64_encode((string)gzencode(serialize($files), 9));
if (strlen($store) < 64 || strlen($store) > 8 * 1024 * 1024) {
return '站点目录不可写,且图片较大无法内置(请给 uploads/site 写权限)';
}
}
$pdo = db();
$old = site_icon_key();
$st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v');
$st->execute(['site_icon', $key]);
$st->execute(['site_icon_files', $store]);
settings_reload(); // 同一次请求里后面还要用新键拼 URL(见 config.php 的说明)
if ($old !== '' && $old !== $key) {
delete_site_logo_files($old); // 换图就换键,旧目录整体清掉
}
return '';
}
/** 恢复默认图标:清掉设置与文件(前台退回内置 logo/favicon) */
function reset_site_logo(): void
{
$old = site_icon_key();
$pdo = db();
$pdo->exec("DELETE FROM settings WHERE k IN ('site_icon','site_icon_files')");
settings_reload(); // 同一次请求里后面还要判断「已回到默认」
if ($old !== '') {
delete_site_logo_files($old);
}
}
/** 删掉一套站点图标文件(只删 uploads/site/<16位hex>/ 这一层,绝不递归到外面) */
function delete_site_logo_files(string $key): void
{
if (!preg_match('/^[a-f0-9]{16,64}$/', $key)) {
return; // 白名单:键不合法就什么都不做
}
$dir = SITE_ICON_PATH . '/' . $key;
if (!is_dir($dir)) {
return;
}
foreach ((array)@scandir($dir) as $f) {
if ($f === '.' || $f === '..') {
continue;
}
$abs = $dir . '/' . $f;
if (is_file($abs)) {
@unlink($abs);
}
}
@rmdir($dir);
}
/** 取出整套图标(动态输出用);没有返回 [] */
function site_icon_all(): array
{
$raw = setting('site_icon_files', '');
if ($raw === '' || strlen($raw) < 64) {
return [];
}
$bin = @base64_decode($raw, true);
if ($bin === false) {
return [];
}
$ser = @gzdecode($bin);
if ($ser === false) {
return [];
}
$arr = @unserialize($ser, ['allowed_classes' => false]);
return is_array($arr) ? $arr : [];
}
/** 站点图标当前是「自定义」还是内置默认(供前台输出可断言标记) */
function site_icon_mode(): string
{
return site_icon_key() === '' ? 'default' : 'custom';
}
/**
* `<head>` 里的图标声明(favicon 多尺寸 / apple-touch / manifest)。
*
* **首页、登录页、后台三处共用这一个函数**:图标是全局的一件事,
* 任何一页单独写死就会出现「首页换了、登录页还是旧的」这种半生效。
* 有自定义站点图标时整套走自定义,否则完全退回内置资源(= 改造前的行为)。
*/
function site_icon_head(): string
{
$pick = static function (string $name, int $size, string $fallback): string {
$u = site_icon_url($name, $size);
return $u !== '' ? $u : asset($fallback);
};
$lines = [
'<link rel="icon" href="' . e($pick('', 32, 'favicon.ico')) . '" sizes="any">',
'<link rel="icon" type="image/png" sizes="32x32" href="' . e($pick('icon-32', 32, 'assets/icons/icon-32.png')) . '">',
'<link rel="icon" type="image/png" sizes="16x16" href="' . e($pick('icon-16', 16, 'assets/icons/icon-16.png')) . '">',
'<link rel="apple-touch-icon" sizes="180x180" href="' . e($pick('apple-touch-icon', 180, 'apple-touch-icon.png')) . '">',
'<link rel="manifest" href="' . e(url('manifest.php')) . '">',
];
return implode("\n", $lines);
}
/* =========================================================================
* 杂项
* ========================================================================= */
/** 把可能为相对路径的 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)];
}