From ff818a3deb167f89dd763102cf02a28d2f20d351 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 12:48:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(nav):=20=E5=AF=BC=E8=88=AA=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E4=B8=8A=E4=BC=A0=EF=BC=88=E5=90=AB=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E8=BF=9E=E5=B8=A6=E4=BF=AE=E5=A4=8D=EF=BC=89=20#16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:网页版导航,新增/编辑导航地址时没有自定义图标功能。 ## 功能 - 站点表单加 enctype=multipart/form-data,新增「自定义图标」控件: 40px 预览 + 选择图片 + 清除自定义图标(仅编辑且有自定义图时出现) - 选择文件后本地即时预览(URL.createObjectURL,用完 revoke,不落 base64) - 按文件内容判类型(finfo + 0) { + if ($hasIconKey && $newIcon !== '') { + // 换图标:先记下旧文件名,落库之后再删(删早了万一 UPDATE 失败就丢图) + $old = (string)(self::site($personId, $id)['icon'] ?? ''); + Database::get()->prepare( + 'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, + is_visible = ?, icon = ?, icon_custom = 1, updated_at = ? + WHERE id = ? AND person_id = ?' + )->execute([$gid, $url, $name, $short, $vis, $newIcon, $now, $id, $personId]); + if ($old !== '' && $old !== $newIcon && !self::iconFileInUse($old, $id)) { + self::deleteIconFile($old); + } + return $id; + } + if ($hasIconKey && $newIcon === '') { + // 清空自定义图标:icon 也清掉,让 iconUrl() 回落到字母块 / 下次抓取 + $old = (string)(self::site($personId, $id)['icon'] ?? ''); + Database::get()->prepare( + 'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, + is_visible = ?, icon = \'\', icon_custom = 0, updated_at = ? + WHERE id = ? AND person_id = ?' + )->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]); + if ($old !== '' && !self::iconFileInUse($old, $id)) { + self::deleteIconFile($old); + } + return $id; + } + // 普通的"改名字/改板块":icon 列一个字都不碰 Database::get()->prepare( 'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, is_visible = ?, updated_at = ? WHERE id = ? AND person_id = ?' )->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]); return $id; } + // ⚠️ 新增一律排**最后**(SimpleNav 的明确行为:不插进用户已排好的顺序里) $sort = self::nextSort('nav_sites', $personId, 'group_id', $gid); Database::get()->prepare( 'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom, sort_order, is_visible, created_at, updated_at) - VALUES (?,?,?,?,?,\'\',0,?,?,?,?)' - )->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]); + VALUES (?,?,?,?,?,?,?,?,?,?,?)' + )->execute([$personId, $gid, $url, $name, $short, $newIcon, $iconCustom, + $sort, $vis, $now, $now]); return (int)Database::get()->lastInsertId(); } + /** + * 这个图标文件还有没有**别人**在用(除 $exceptSiteId 之外)。 + * + * 🔴 为什么必须查:图标文件在 SimpleNav 里是"一个网址一个文件"(md5(url)), + * 家书搬过来后仍然沿用这个规则给**抓取**的图标命名 ⇒ 多用户可能指向同一个文件。 + * 换图标时若无脑 unlink,会把别人的图标一起删掉(对方列表里当场变成破图)。 + * ⇒ 删之前先数一下还有几条记录指向它,只剩自己那条才删。 + */ + public static function iconFileInUse(string $file, int $exceptSiteId = 0): bool + { + if ($file === '') { + return false; + } + $st = Database::get()->prepare( + 'SELECT COUNT(*) FROM nav_sites WHERE icon = ? AND id <> ?' + ); + $st->execute([$file, $exceptSiteId]); + return (int)$st->fetchColumn() > 0; + } + public static function removeSite(int $personId, int $id): void { $s = self::site($personId, $id); diff --git a/app/NavFetch.php b/app/NavFetch.php index 20c1387..c1906ef 100644 --- a/app/NavFetch.php +++ b/app/NavFetch.php @@ -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('#]*>(.*?)#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. -