From d05167103b415cd0b0e1a454271803af4e85ff67 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 14 Feb 2026 21:30:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BD=91=E7=AB=99=E8=AE=BE?= =?UTF-8?q?=E7=BD=AEfavicon=E4=B8=8D=E7=94=9F=E6=95=88=E4=B8=8E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=91=BD=E4=B8=AD=E9=BB=98=E8=AE=A4=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/favicon/route.ts | 65 +++++++++++++++++++++++++++++++--- app/layout.tsx | 10 +++++- components/favicon-updater.tsx | 41 +++++++++++++-------- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 7972c3167..52d3851e4 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -8,6 +8,7 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; const DEFAULT_CONTENT_TYPE = "image/x-icon"; +const FETCH_TIMEOUT_MS = 10_000; function noStoreHeaders(contentType: string) { return { @@ -27,6 +28,34 @@ function inferContentType(url: string) { return DEFAULT_CONTENT_TYPE; } +function resolveFaviconUrl(rawUrl: string, requestUrl: string) { + const input = rawUrl.trim(); + if (!input) { + return null; + } + + try { + return new URL(input).toString(); + } catch { + // ignore and try resolving relative path + } + + const authUrl = process.env.AUTH_URL?.trim(); + if (authUrl) { + try { + return new URL(input, authUrl).toString(); + } catch { + // ignore and fallback to request origin + } + } + + try { + return new URL(input, requestUrl).toString(); + } catch { + return null; + } +} + async function readLocalFallbackFavicon() { const file = await readFile(join(process.cwd(), "app", "favicon.ico")); return new NextResponse(file, { @@ -35,19 +64,46 @@ async function readLocalFallbackFavicon() { }); } -export async function GET() { +export async function GET(request: Request) { try { const settings = await getSiteSettings(); - const faviconUrl = String(settings?.favicon || "").trim(); + const rawFaviconUrl = String(settings?.favicon || "").trim(); + if (!rawFaviconUrl) { + return await readLocalFallbackFavicon(); + } + + const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url); if (!faviconUrl) { + console.error("[favicon] Invalid favicon URL:", rawFaviconUrl); return await readLocalFallbackFavicon(); } const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; - const upstream = await fetch(targetUrl, { cache: "no-store" }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + let upstream: Response; + try { + upstream = await fetch(targetUrl, { + cache: "no-store", + signal: controller.signal, + redirect: "follow", + headers: { + Accept: "image/*,*/*;q=0.8", + "User-Agent": "Mozilla/5.0 (compatible; BuyiAIFavicon/1.0)", + }, + }); + } finally { + clearTimeout(timeout); + } if (!upstream.ok) { + console.error("[favicon] Upstream request failed:", { + targetUrl, + status: upstream.status, + statusText: upstream.statusText, + }); return await readLocalFallbackFavicon(); } @@ -59,7 +115,8 @@ export async function GET() { status: 200, headers: noStoreHeaders(contentType), }); - } catch (_error) { + } catch (error) { + console.error("[favicon] Failed to proxy favicon:", error); return await readLocalFallbackFavicon(); } } diff --git a/app/layout.tsx b/app/layout.tsx index ca8c65cee..55bc9d82c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,9 +11,17 @@ import { getSiteSettings } from "@/lib/db/queries"; export async function generateMetadata(): Promise { const settings = await getSiteSettings(); - const faviconUrl = "/api/favicon"; + const updatedAt = + "updatedAt" in settings && settings.updatedAt + ? new Date(settings.updatedAt).getTime() + : Date.now(); + const faviconUrl = `/api/favicon?v=${updatedAt}`; + const metadataBase = process.env.AUTH_URL + ? new URL(process.env.AUTH_URL) + : undefined; return { + metadataBase, title: { template: `%s | ${settings.siteName}`, default: settings.siteName, diff --git a/components/favicon-updater.tsx b/components/favicon-updater.tsx index 4f969d959..e51ab2dbf 100644 --- a/components/favicon-updater.tsx +++ b/components/favicon-updater.tsx @@ -14,30 +14,43 @@ export function FaviconUpdater() { return; } - // 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 - const oldLinks = document.querySelectorAll( - 'link[data-managed-favicon="true"]' + const managedSelector = + 'link[rel="icon"],link[rel="shortcut icon"],link[rel="apple-touch-icon"]'; + const existingLinks = document.querySelectorAll( + managedSelector ); - oldLinks.forEach((link) => { - if (link.parentNode) { - link.parentNode.removeChild(link); + + existingLinks.forEach((link) => { + link.href = urlWithCache; + link.setAttribute("data-managed-favicon", "true"); + if (link.rel === "icon") { + link.type = "image/x-icon"; + link.setAttribute("sizes", "any"); } }); - const createManagedLink = (rel: string, href: string, type?: string) => { - const link = document.createElement("link"); - link.rel = rel; - link.href = href; + const ensureLink = (rel: string, type?: string) => { + let link = document.querySelector( + `link[rel="${rel}"]` + ) as HTMLLinkElement | null; + if (!link) { + link = document.createElement("link"); + link.rel = rel; + document.head.appendChild(link); + } + link.href = urlWithCache; link.setAttribute("data-managed-favicon", "true"); if (type) { link.type = type; } - document.head.appendChild(link); + if (rel === "icon") { + link.setAttribute("sizes", "any"); + } }; - createManagedLink("icon", urlWithCache, "image/x-icon"); - createManagedLink("apple-touch-icon", urlWithCache); - createManagedLink("shortcut icon", urlWithCache); + ensureLink("icon", "image/x-icon"); + ensureLink("apple-touch-icon"); + ensureLink("shortcut icon"); }); return () => {