修复网站设置favicon不生效与缓存命中默认图标
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 21:30:06 +08:00
parent f2b12b251d
commit d05167103b
3 changed files with 97 additions and 19 deletions

View File

@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
export const revalidate = 0; export const revalidate = 0;
const DEFAULT_CONTENT_TYPE = "image/x-icon"; const DEFAULT_CONTENT_TYPE = "image/x-icon";
const FETCH_TIMEOUT_MS = 10_000;
function noStoreHeaders(contentType: string) { function noStoreHeaders(contentType: string) {
return { return {
@ -27,6 +28,34 @@ function inferContentType(url: string) {
return DEFAULT_CONTENT_TYPE; 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() { async function readLocalFallbackFavicon() {
const file = await readFile(join(process.cwd(), "app", "favicon.ico")); const file = await readFile(join(process.cwd(), "app", "favicon.ico"));
return new NextResponse(file, { return new NextResponse(file, {
@ -35,19 +64,46 @@ async function readLocalFallbackFavicon() {
}); });
} }
export async function GET() { export async function GET(request: Request) {
try { try {
const settings = await getSiteSettings(); 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) { if (!faviconUrl) {
console.error("[favicon] Invalid favicon URL:", rawFaviconUrl);
return await readLocalFallbackFavicon(); return await readLocalFallbackFavicon();
} }
const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; 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) { if (!upstream.ok) {
console.error("[favicon] Upstream request failed:", {
targetUrl,
status: upstream.status,
statusText: upstream.statusText,
});
return await readLocalFallbackFavicon(); return await readLocalFallbackFavicon();
} }
@ -59,7 +115,8 @@ export async function GET() {
status: 200, status: 200,
headers: noStoreHeaders(contentType), headers: noStoreHeaders(contentType),
}); });
} catch (_error) { } catch (error) {
console.error("[favicon] Failed to proxy favicon:", error);
return await readLocalFallbackFavicon(); return await readLocalFallbackFavicon();
} }
} }

View File

@ -11,9 +11,17 @@ import { getSiteSettings } from "@/lib/db/queries";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings(); 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 { return {
metadataBase,
title: { title: {
template: `%s | ${settings.siteName}`, template: `%s | ${settings.siteName}`,
default: settings.siteName, default: settings.siteName,

View File

@ -14,30 +14,43 @@ export function FaviconUpdater() {
return; return;
} }
// 仅处理本组件创建的 link,避免删除 Next/React 管理的 head 节点 const managedSelector =
const oldLinks = document.querySelectorAll( 'link[rel="icon"],link[rel="shortcut icon"],link[rel="apple-touch-icon"]';
'link[data-managed-favicon="true"]' const existingLinks = document.querySelectorAll<HTMLLinkElement>(
managedSelector
); );
oldLinks.forEach((link) => {
if (link.parentNode) { existingLinks.forEach((link) => {
link.parentNode.removeChild(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 ensureLink = (rel: string, type?: string) => {
const link = document.createElement("link"); let link = document.querySelector(
link.rel = rel; `link[rel="${rel}"]`
link.href = href; ) 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"); link.setAttribute("data-managed-favicon", "true");
if (type) { if (type) {
link.type = type; link.type = type;
} }
document.head.appendChild(link); if (rel === "icon") {
link.setAttribute("sizes", "any");
}
}; };
createManagedLink("icon", urlWithCache, "image/x-icon"); ensureLink("icon", "image/x-icon");
createManagedLink("apple-touch-icon", urlWithCache); ensureLink("apple-touch-icon");
createManagedLink("shortcut icon", urlWithCache); ensureLink("shortcut icon");
}); });
return () => { return () => {