diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 000000000..7972c3167 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { NextResponse } from "next/server"; +import { getSiteSettings } from "@/lib/db/queries"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const DEFAULT_CONTENT_TYPE = "image/x-icon"; + +function noStoreHeaders(contentType: string) { + return { + "Content-Type": contentType, + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", + Expires: "0", + }; +} + +function inferContentType(url: string) { + const lower = url.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + return DEFAULT_CONTENT_TYPE; +} + +async function readLocalFallbackFavicon() { + const file = await readFile(join(process.cwd(), "app", "favicon.ico")); + return new NextResponse(file, { + status: 200, + headers: noStoreHeaders(DEFAULT_CONTENT_TYPE), + }); +} + +export async function GET() { + try { + const settings = await getSiteSettings(); + const faviconUrl = String(settings?.favicon || "").trim(); + + if (!faviconUrl) { + return await readLocalFallbackFavicon(); + } + + const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; + const upstream = await fetch(targetUrl, { cache: "no-store" }); + + if (!upstream.ok) { + return await readLocalFallbackFavicon(); + } + + const buffer = await upstream.arrayBuffer(); + const contentType = + upstream.headers.get("content-type") || inferContentType(faviconUrl); + + return new NextResponse(buffer, { + status: 200, + headers: noStoreHeaders(contentType), + }); + } catch (_error) { + return await readLocalFallbackFavicon(); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 0d4e29456..ca8c65cee 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,11 +11,7 @@ import { getSiteSettings } from "@/lib/db/queries"; export async function generateMetadata(): Promise { const settings = await getSiteSettings(); - - // 处理 favicon URL,添加缓存破坏参数确保更新 - const faviconUrl = settings.favicon - ? `${settings.favicon}${settings.favicon.includes('?') ? '&' : '?'}v=${Date.now()}` - : "/favicon.ico"; + const faviconUrl = "/api/favicon"; return { title: { diff --git a/components/favicon-updater.tsx b/components/favicon-updater.tsx index 43d127e9c..4f969d959 100644 --- a/components/favicon-updater.tsx +++ b/components/favicon-updater.tsx @@ -6,47 +6,40 @@ export function FaviconUpdater() { useEffect(() => { let isDisposed = false; - // 从 API 获取最新的 favicon 设置 - fetch("/api/site-settings") - .then((res) => res.json()) - .then((settings) => { - if (!settings.favicon || isDisposed) { - return; + const timestamp = Date.now(); + const urlWithCache = `/api/favicon?v=${timestamp}`; + + Promise.resolve().then(() => { + if (isDisposed) { + return; + } + + // 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 + const oldLinks = document.querySelectorAll( + 'link[data-managed-favicon="true"]' + ); + oldLinks.forEach((link) => { + if (link.parentNode) { + link.parentNode.removeChild(link); } - - const faviconUrl = settings.favicon; - const timestamp = Date.now(); - const urlWithCache = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${timestamp}`; - - // 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 - const oldLinks = document.querySelectorAll( - 'link[data-managed-favicon="true"]' - ); - oldLinks.forEach((link) => { - if (link.parentNode) { - link.parentNode.removeChild(link); - } - }); - - const createManagedLink = (rel: string, href: string, type?: string) => { - const link = document.createElement("link"); - link.rel = rel; - link.href = href; - link.setAttribute("data-managed-favicon", "true"); - if (type) { - link.type = type; - } - document.head.appendChild(link); - }; - - createManagedLink("icon", urlWithCache, "image/x-icon"); - createManagedLink("apple-touch-icon", urlWithCache); - createManagedLink("shortcut icon", urlWithCache); - }) - .catch((error) => { - console.error("[Favicon] Error updating favicon:", error); }); + const createManagedLink = (rel: string, href: string, type?: string) => { + const link = document.createElement("link"); + link.rel = rel; + link.href = href; + link.setAttribute("data-managed-favicon", "true"); + if (type) { + link.type = type; + } + document.head.appendChild(link); + }; + + createManagedLink("icon", urlWithCache, "image/x-icon"); + createManagedLink("apple-touch-icon", urlWithCache); + createManagedLink("shortcut icon", urlWithCache); + }); + return () => { isDisposed = true; };