按反馈逐条处理: ① 界面美化 - 前台重做:品牌区(logo + 站名 + 副标题)、胶囊式引擎切换、圆角卡片与 悬停浮起、聚焦光晕、合集标签页淡入动画;版心改为 shell 变量 (--pad-x = max(gutter, (100% - wrap)/2)),与顶栏左右边缘严格对齐。 - 后台:表格窄屏可横向滚动、状态胶囊、自定义徽章、图标预览框。 ② 自动更新图标质量差 + 允许自定义图标 - 抓取改为阶梯择优:manifest 512/192 → apple-touch-icon → <link rel=icon> (按 sizes 降序,SVG 视为最高清)→ 第三方聚合服务(favicon.yandex.net 实测 100% 可用;icon.horse 可达时最高 256px)→ 站点自身 /favicon.ico。 - 逐个候选实测像素,达到 120px 即停;候选上限 8 个 + 10s 总预算,避免超时。 - 用 GD 只缩不放,统一存成 128×128 PNG;识别并丢弃第三方服务的 「灰底字母」占位块。 - 新增 sites.icon_custom 列(带幂等迁移):可上传图标或填图标地址; 一旦自定义,「全部更新」只刷新名称、跳过图标。 ③ 后台引擎「可见/不可见」不好用 - 小图标换成内联 SVG(原来用 emoji,跨字体显示成方块)。 - 切换改为 AJAX 原地生效 + 状态胶囊同步,不再点一下就跳页; 所有切换/移动表单都带 p 参数,无 JS 时也能正确回到当前页。 - 修掉一个隐蔽的成因:本站此前完全不发 Cache-Control/Expires/ETag, 浏览器只能自行猜测缓存策略,导致「改完刷新还是旧的」。现在所有动态页 统一发 no-store/no-cache。 ④ deploy/ 与 data/ 误入版本库 - 两目录整目录忽略,部署脚本移出仓库(存 _tooling/),已 git rm --cached。 (历史提交里仍含这些文件,如需彻底抹除需 git filter-repo,待确认。) 附带修复(线上实测驱动) - 服务器侧页面缓存:宝塔「网站加速」Apache(mod_lua) 版是一层基于 memcached 的 30 秒整页缓存,且**读不到 FastCGI 返回的 Cache-Control**,所以 no-store 挡不住它;唯一信号是请求 cookie X_CACHE=1。已在根 .htaccess 用 RequestHeader 在请求侧注入该标记(实测任何访客均 X-Cache: MISS), PHP 侧另发一颗 cookie 作为 Nginx 环境的兜底(BYPASS_PAGE_CACHE 开关)。 - 静态资源改为「长缓存 + 版本号」:asset()/icon_src() 拼 mtime+size, assets/.htaccess 与根 .htaccess 给出 max-age=31536000 immutable。 - 修 absolutize() 未还原 JSON 转义 \/ 导致部分图标 URL 抓不下来的问题。 - 修部署脚本 md5sum 在 Git Bash 下输出 "hash *path" 导致 38 项假「缺失文件」。
670 lines
24 KiB
PHP
670 lines
24 KiB
PHP
<?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;
|
||
}
|
||
|
||
/** 取主域名(用于图标文件名与首字母回退) */
|
||
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 '';
|
||
}
|
||
|
||
/* =========================================================================
|
||
* 图标下载与规范化
|
||
* ========================================================================= */
|
||
|
||
/**
|
||
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
|
||
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
|
||
*
|
||
* @param string[] $urls 候选图标地址(已按优先级排序)
|
||
*/
|
||
function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string
|
||
{
|
||
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];
|
||
}
|
||
|
||
/** 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名 */
|
||
function save_custom_icon(string $binary, string $key): string
|
||
{
|
||
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 里 n.yangyq.net 这节,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)];
|
||
}
|