Files
laoyang ff818a3deb feat(nav): 导航站点支持自定义图标上传(含两处连带修复) #16
需求:网页版导航,新增/编辑导航地址时没有自定义图标功能。

## 功能
- 站点表单加 enctype=multipart/form-data,新增「自定义图标」控件:
  40px 预览 + 选择图片 + 清除自定义图标(仅编辑且有自定义图时出现)
- 选择文件后本地即时预览(URL.createObjectURL,用完 revoke,不落 base64)
- 按文件内容判类型(finfo + <svg 探测),不信任扩展名;上限 1MB
- icon_custom 三态语义:不选文件=不动 icon 列;选文件=icon_custom=1;
  勾清除=icon='' & icon_custom=0。普通改名不碰 icon 列,不会丢图
- saveUploaded() 改随机文件名。原实现按 md5(icon_key(url)) 命名,
  单用户系统没事、多用户会互相覆盖
- 新增 Nav::iconFileInUse():图标文件按内容共享,删旧文件前先查还有没有
  别的记录在用
- 图标从 uploads/ 目录取不到(线上 403),走 /nav_icon.php?f= 公开出口

## 连带修复 1:ICON_STORE 常量从未定义
SimpleNav 搬迁时漏搬 define('ICON_STORE', 128),NavFetch.php 第 667/739
行在用。PHP 8 抛 Error: Undefined constant "ICON_STORE",只要走到 GD
位图分支就 500。因为 saveUploaded() 一直是死代码(有定义无调用点),
这个错一直没被触发过。现补进 app/bootstrap.php(带 if (!defined) 保护)。

## 连带修复 2:22 处裸函数调用缺 self::
搬迁时把自由函数改成了 NavFetch 的静态方法,但类内部调用点没跟着改。
PHP 报 Error: Call to undefined function App\http_get() @ NavFetch.php:171。
这既是「全部更新」按钮 500 的根因,也是「图标一直抓不到」的真相 ——
整条抓取链路(http_get / attr_value / absolutize / third_party_icon_urls /
default_icon_candidates / meta_content / parse_icon_links / manifest_icons /
is_third_party_icon / looks_generated_tile / grab_icon / ensure_utf8)
一处都没跑起来过。

修后 CLI 实测 NavFetch::meta('https://www.baidu.com') 返回
name=[百度一下,你就知道]、icons=3、图标落盘 3818 字节。

## 验证
- php -l 5 个文件全过
- 端到端 21/0:真实上传 PNG → 列表出 img.nav-ico → nav_icon.php 返 200
  + PNG 头 → 编辑换图 → 清除 → 只改简称不丢图标 → 清理
- 语义验收:带自定义图的站点点「全部更新」后 icon 文件名不变、custom=1;
  不传图的站点 custom=0(以服务端回报条数为证据,不看"未变")
2026-10-28 12:48:47 +08:00

908 lines
36 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
/**
* 导航的「网站元信息与图标抓取」(v24 第 24 批 用户第 4 条)——
* **从用户自己的 SimpleNav 项目整段搬运**,逻辑一行未改,只做了三处机械改写:
*
* ① 自由函数 → `final class NavFetch` 的私有静态方法(家书是类的风格);
* ② 常量 `ICON_PATH` / `ICON_URL` → `Nav::ICON_DIR` / `Nav::ICON_URL`;
* ③ 与家书**重名**的几个(`e` / `norm_url` / `domain_of` / `letter_color`)改调
* `Nav::` 上的同名方法 —— 不搬一份进来,否则函数重定义直接 Fatal。
*
* ⚠️ 原文里那些"线上实测"的注释**全部保留**(例如"哪几个第三方图标服务可达、
* 哪几个不可达")。它们是这个子系统里最贵的部分 —— 重踩一遍要花一整轮。
*
* ⚠️ 这一批方法是**要联网的**(每个站点一次 HTTP + 可能多次图标下载)。
* 所以只在用户**主动点「抓取」/「全部更新」**时调,页面渲染绝不调。
*/
declare(strict_types=1);
namespace App;
final class NavFetch
{
/** 第三方图标聚合服务开关(原项目是后台设置 icon_services) */
private static bool $iconServices = true;
public static function setIconServices(bool $on): void
{
self::$iconServices = $on;
}
private static function iconServices(): string
{
return self::$iconServices ? '1' : '0';
}
/** HTTP 抓取(带 UA / 超时 / 跟随跳转)—— 供外部复用 */
public static function get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
return self::http_get($url, $maxBytes, $timeout);
}
/**
* 抓一个站点的元信息(标题 / 名称 + 图标候选)—— 对外的唯一入口。
*
* 搬进来的原函数叫 `fetch_site_meta()`,这里只是换一个符合家书命名风格的
* public 名字,逻辑一行没动。
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
public static function meta(string $url): array
{
return self::fetch_site_meta($url);
}
/**
* 抓取并**落盘**一个站点的图标,返回文件名(失败返回 '')。
*
* 这是 SimpleNav「抓图标」那段业务逻辑的落点:按清晰度依次尝试候选
* (manifest 大图 → apple-touch → link[rel=icon] → 第三方聚合 → /favicon.ico),
* 内部就是 `download_best_icon()` —— 逐个下载、挑最清晰的一张、存成统一尺寸。
*
* @param array $meta self::meta() 的返回值
* @param string $url 站点地址(文件名 = md5(url),与 SimpleNav 同规则)
* @param bool $allowThirdParty 允许用第三方图标聚合服务
* (会让这一步多发几次外网请求;哪些可达见
* third_party_icon_urls 里那批实测注释)
*/
public static function fetchIcon(array $meta, string $url, bool $allowThirdParty = true): string
{
self::$iconServices = $allowThirdParty;
$key = self::icon_key($url);
if ($key === '') {
return '';
}
$dir = FB_ROOT . '/' . Nav::ICON_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::download_best_icon((array)($meta['icons'] ?? []), $key);
}
/**
* 用户自传的图标二进制落盘,返回文件名。
*
* ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 ——
* 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。
*
* 🔴 工单 #16:这里**不能**再用 `icon_key($url)`(= md5(网址))当文件名。
* 原项目 SimpleNav 是**单用户**系统,"一个网址一个图标文件"没问题;
* 家书是**多用户**的,两个人给同一个网址上传不同图标时,
* md5(url) 会让后传的**直接覆盖**先传的 —— A 在自己导航里看到 B 的图标,
* 而且 A 那条的 `icon` 字段还指向这个文件,等于两个人的图标互相串。
*
* 改用 `random_bytes` 生成**每次唯一**的文件名(与 `Nav::copyIconFile()`
* 同一套做法):上传一次一个文件,换图标就换文件名,
* 旧的由调用方显式删除(`Nav::deleteIconFile`,且只在没人复用时删)。
*/
public static function saveUploaded(string $binary, string $url = ''): string
{
// $url 保留在签名里只为兼容旧调用;不再参与文件命名(见上面说明)。
// save_custom_icon 内部取 md5($key) 当文件名 ⇒ 传一个随机键即得唯一文件名
// (结果是 32 位十六进制,正好符合 nav_icon.php 的白名单)。
return self::save_custom_icon($binary, bin2hex(random_bytes(16)));
}
/** curl 抓取(带 UA、超时、跟随跳转) */
private static 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}
*/
/**
* 抓取网站元信息。
* 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}
*/
private static function fetch_site_meta(string $url): array
{
$url = Nav::normUrl($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
$resp = self::http_get($url);
if ($resp === null) {
$out['icons'] = array_merge(self::third_party_icon_urls($url), self::default_icon_candidates($url));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
$html = self::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'] = self::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] = self::parse_icon_links($html, $final);
$icons = [];
if ($manifestUrl !== '') {
foreach (self::manifest_icons($manifestUrl) as $u) {
$icons[] = $u;
}
}
foreach ($links as $u) {
$icons[] = $u;
}
foreach (self::third_party_icon_urls($final) as $u) {
$icons[] = $u;
}
foreach (self::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 关闭。
*/
/**
* 联网搜索高清图标的第三方聚合服务。
* 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG);
* 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。
* 可在后台「系统设置」里用 icon_services 关闭。
*/
private static function third_party_icon_urls(string $url): array
{
if (self::iconServices() !== '1') {
return [];
}
$host = parse_url(Nav::normUrl($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="..."> 的值(属性顺序无关) */
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
private static 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]
*/
/**
* 解析页面里的 <link> 图标声明。
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
*/
private static 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 = self::attr_value($tag, 'rel');
if ($relv === '') {
continue;
}
$relv = strtolower($relv);
$href = html_entity_decode(self::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 = self::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(self::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[] = self::absolutize($h, $base); }
foreach ($rel as $r) { $list[] = self::absolutize($r['url'], $base); }
return [$list, $manifest === '' ? '' : self::absolutize($manifest, $base)];
}
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
private static function manifest_icons(string $manifestUrl): array
{
$r = self::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' => self::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 */
/** 兜底候选:站点自身 favicon.ico */
private static function default_icon_candidates(string $url): array
{
$p = parse_url(Nav::normUrl($url));
if (empty($p['host'])) {
return [];
}
$scheme = !empty($p['scheme']) ? $p['scheme'] : 'https';
return [$scheme . '://' . $p['host'] . '/favicon.ico'];
}
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
private static 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 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定,
* 不影响任何行为)。
*/
/**
* 图标文件的命名键:**按整条网址**算,而不是只按域名。
*
* 为什么不能只用域名(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 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定,
* 不影响任何行为)。
*/
private static 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)**(不要传域名)
*/
/**
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
*
* @param string[] $urls 候选图标地址(已按优先级排序)
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
private static function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string
{
if ($key === '') { // 没有键就不写文件,避免全部落到 md5('') 上互相覆盖
return '';
}
if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) {
@mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 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 = self::grab_icon($u);
if ($r === null) {
continue;
}
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
if ($r['ext'] === 'png' && self::is_third_party_icon($u) && self::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 = FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file;
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
if (!@copy($best['tmp'], $dest)) {
@unlink($best['tmp']);
return '';
}
@unlink($best['tmp']);
return is_file($dest) ? $file : '';
}
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
private static 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 都不会命中。
*/
/**
* 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。
* 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面,
* 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。
*/
private static 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 为「原始」尺寸,用于择优
*/
/**
* 下载单个图标到临时文件。
* @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优
*/
private static function grab_icon(string $iconUrl): ?array
{
$resp = self::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)**(不要传域名)
*/
/**
* 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。
*
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
private static function save_custom_icon(string $binary, string $key): string
{
if ($key === '') {
return '';
}
if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) {
@mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 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(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
@file_put_contents(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, $binary);
return is_file(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $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, FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
return '';
}
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
return $file;
}
/* =========================================================================
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
*
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
* ========================================================================= */
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
private static function gd_load(string $binary)
{
if (!function_exists('imagecreatefromstring')) {
return null;
}
$im = @imagecreatefromstring($binary);
return $im === false ? null : $im;
}
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
private static 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')
*/
/** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */
private static 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;
}
/** 整套图标清单:文件基名 => 尺寸 */
/** 把可能为相对路径的 URL 补全为绝对 URL */
private static 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 */
/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
private static 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;
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
}