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(以服务端回报条数为证据,不看"未变")
This commit is contained in:
+35
-22
@@ -84,10 +84,23 @@ final class NavFetch
|
||||
*
|
||||
* ⚠️ 落盘后调用方**必须**把 `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
|
||||
public static function saveUploaded(string $binary, string $url = ''): string
|
||||
{
|
||||
return self::save_custom_icon($binary, self::icon_key($url));
|
||||
// $url 保留在签名里只为兼容旧调用;不再参与文件命名(见上面说明)。
|
||||
// save_custom_icon 内部取 md5($key) 当文件名 ⇒ 传一个随机键即得唯一文件名
|
||||
// (结果是 32 位十六进制,正好符合 nav_icon.php 的白名单)。
|
||||
return self::save_custom_icon($binary, bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
/** curl 抓取(带 UA、超时、跟随跳转) */
|
||||
@@ -155,14 +168,14 @@ final class NavFetch
|
||||
$url = Nav::normUrl($url);
|
||||
$out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
|
||||
|
||||
$resp = http_get($url);
|
||||
$resp = self::http_get($url);
|
||||
if ($resp === null) {
|
||||
$out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url));
|
||||
$out['icons'] = array_merge(self::third_party_icon_urls($url), self::default_icon_candidates($url));
|
||||
$out['icon'] = $out['icons'][0] ?? '';
|
||||
return $out;
|
||||
}
|
||||
|
||||
$html = ensure_utf8((string)$resp['body']);
|
||||
$html = self::ensure_utf8((string)$resp['body']);
|
||||
$final = $resp['url'] !== '' ? $resp['url'] : $url;
|
||||
$out['final_url'] = $final;
|
||||
|
||||
@@ -170,7 +183,7 @@ final class NavFetch
|
||||
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');
|
||||
$out['name'] = self::meta_content($html, 'og:site_name');
|
||||
if ($out['name'] === '' && $out['title'] !== '') {
|
||||
// 用标题的首段(按分隔符切分)作为名称
|
||||
$seg = preg_split('#[\|\-_—·::]#u', $out['title']);
|
||||
@@ -183,20 +196,20 @@ final class NavFetch
|
||||
// 3. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
|
||||
// 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
|
||||
// 5. 兜底:站点自身 /favicon.ico
|
||||
[$links, $manifestUrl] = parse_icon_links($html, $final);
|
||||
[$links, $manifestUrl] = self::parse_icon_links($html, $final);
|
||||
$icons = [];
|
||||
if ($manifestUrl !== '') {
|
||||
foreach (manifest_icons($manifestUrl) as $u) {
|
||||
foreach (self::manifest_icons($manifestUrl) as $u) {
|
||||
$icons[] = $u;
|
||||
}
|
||||
}
|
||||
foreach ($links as $u) {
|
||||
$icons[] = $u;
|
||||
}
|
||||
foreach (third_party_icon_urls($final) as $u) {
|
||||
foreach (self::third_party_icon_urls($final) as $u) {
|
||||
$icons[] = $u;
|
||||
}
|
||||
foreach (default_icon_candidates($final) as $u) {
|
||||
foreach (self::default_icon_candidates($final) as $u) {
|
||||
$icons[] = $u;
|
||||
}
|
||||
|
||||
@@ -274,12 +287,12 @@ final class NavFetch
|
||||
|
||||
if (preg_match_all('#<link\b[^>]*>#i', $html, $tags)) {
|
||||
foreach ($tags[0] as $tag) {
|
||||
$relv = attr_value($tag, 'rel');
|
||||
$relv = self::attr_value($tag, 'rel');
|
||||
if ($relv === '') {
|
||||
continue;
|
||||
}
|
||||
$relv = strtolower($relv);
|
||||
$href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
|
||||
$href = html_entity_decode(self::attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
|
||||
if ($href === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -295,7 +308,7 @@ final class NavFetch
|
||||
continue;
|
||||
}
|
||||
$size = 0;
|
||||
$raw = attr_value($tag, 'sizes');
|
||||
$raw = self::attr_value($tag, 'sizes');
|
||||
if ($raw !== '') {
|
||||
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) {
|
||||
if (stripos($one, 'any') !== false) {
|
||||
@@ -306,7 +319,7 @@ final class NavFetch
|
||||
}
|
||||
}
|
||||
// SVG 是矢量,视为最高清
|
||||
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) {
|
||||
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(self::attr_value($tag, 'type'), 'svg') !== false) {
|
||||
$size = max($size, 512);
|
||||
}
|
||||
$rel[] = ['size' => $size, 'url' => $href];
|
||||
@@ -316,9 +329,9 @@ final class NavFetch
|
||||
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)];
|
||||
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,返回其中图标(按尺寸降序) */
|
||||
@@ -326,7 +339,7 @@ final class NavFetch
|
||||
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
|
||||
private static function manifest_icons(string $manifestUrl): array
|
||||
{
|
||||
$r = http_get($manifestUrl, 200000, 6);
|
||||
$r = self::http_get($manifestUrl, 200000, 6);
|
||||
if ($r === null) {
|
||||
return [];
|
||||
}
|
||||
@@ -350,7 +363,7 @@ final class NavFetch
|
||||
}
|
||||
}
|
||||
}
|
||||
$list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)];
|
||||
$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')));
|
||||
@@ -488,13 +501,13 @@ final class NavFetch
|
||||
break;
|
||||
}
|
||||
$tried++;
|
||||
$r = grab_icon($u);
|
||||
$r = self::grab_icon($u);
|
||||
if ($r === null) {
|
||||
continue;
|
||||
}
|
||||
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
|
||||
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
|
||||
if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) {
|
||||
if ($r['ext'] === 'png' && self::is_third_party_icon($u) && self::looks_generated_tile($r['tmp'])) {
|
||||
@unlink($r['tmp']);
|
||||
continue;
|
||||
}
|
||||
@@ -601,7 +614,7 @@ final class NavFetch
|
||||
*/
|
||||
private static function grab_icon(string $iconUrl): ?array
|
||||
{
|
||||
$resp = http_get($iconUrl, 2 * 1024 * 1024, 5);
|
||||
$resp = self::http_get($iconUrl, 2 * 1024 * 1024, 5);
|
||||
if ($resp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user