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"; const DARK_FAVICON_CONTENT_TYPE = "image/png"; const FETCH_TIMEOUT_MS = 10_000; 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; } 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; } } function uniqueNonEmptyValues(values: string[]) { const seen = new Set(); const result: string[] = []; for (const value of values) { const trimmed = value.trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); result.push(trimmed); } return result; } async function readLocalFallbackFavicon(preferDark = false) { const file = await readFile( join(process.cwd(), "public", "default-favicon.ico") ); if (preferDark) { const transformed = await transformFaviconForDark(file).catch(() => null); const fallback = transformed || (await renderDarkFallbackFavicon().catch(() => null)); if (fallback) { return new NextResponse(fallback, { status: 200, headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), }); } } return new NextResponse(file, { status: 200, headers: noStoreHeaders(DEFAULT_CONTENT_TYPE), }); } async function renderDarkFallbackFavicon() { const sharp = (await import("sharp")).default; const svg = `\ `; return await sharp(Buffer.from(svg)).png().toBuffer(); } async function transformFaviconForDark(buffer: Buffer | ArrayBuffer) { const sharp = (await import("sharp")).default; const inputBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(new Uint8Array(buffer)); const createBaseImage = () => sharp(inputBuffer, { animated: false, failOn: "none", }).resize({ width: 64, height: 64, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }); const { data, info } = await createBaseImage() .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); let visiblePixelCount = 0; let luminanceTotal = 0; for (let index = 0; index < data.length; index += info.channels) { const alpha = data[index + 3] ?? 255; if (alpha < 24) continue; const red = data[index] ?? 0; const green = data[index + 1] ?? 0; const blue = data[index + 2] ?? 0; luminanceTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue; visiblePixelCount += 1; } const averageLuminance = visiblePixelCount > 0 ? luminanceTotal / visiblePixelCount : 0; const needsInvertForDark = averageLuminance < 150; const output = createBaseImage(); if (needsInvertForDark) { output.negate({ alpha: false }); } return await output .png() .toBuffer(); } export async function GET(request: Request) { try { const requestUrl = new URL(request.url); const preferDark = requestUrl.searchParams.get("theme") === "dark"; const settings = await getSiteSettings(); const configuredFavicon = String(settings?.favicon || "").trim(); const configuredLightLogo = String(settings?.siteLogo || "").trim(); const configuredDarkLogo = String(settings?.siteLogoDark || "").trim(); const rawFaviconCandidates = preferDark ? uniqueNonEmptyValues([ configuredFavicon, configuredDarkLogo, configuredLightLogo, ]) : uniqueNonEmptyValues([ configuredFavicon, configuredLightLogo, configuredDarkLogo, ]); if (rawFaviconCandidates.length === 0) { return await readLocalFallbackFavicon(preferDark); } for (const rawFaviconUrl of rawFaviconCandidates) { const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url); if (!faviconUrl) { console.error("[favicon] Invalid favicon URL:", rawFaviconUrl); continue; } const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; 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)", }, }); } catch (error) { console.error("[favicon] Candidate request failed:", { targetUrl, error, }); continue; } finally { clearTimeout(timeout); } if (!upstream.ok) { console.error("[favicon] Upstream request failed:", { targetUrl, status: upstream.status, statusText: upstream.statusText, }); continue; } const buffer = await upstream.arrayBuffer(); const contentType = upstream.headers.get("content-type") || inferContentType(faviconUrl); if (preferDark) { const transformed = await transformFaviconForDark(buffer).catch((error) => { console.error("[favicon] Failed to transform dark favicon candidate:", { targetUrl, error, }); return null; }); if (!transformed) continue; return new NextResponse(transformed, { status: 200, headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), }); } return new NextResponse(buffer, { status: 200, headers: noStoreHeaders(contentType), }); } if (preferDark) { const fallback = await renderDarkFallbackFavicon().catch(() => null); if (fallback) { return new NextResponse(fallback, { status: 200, headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), }); } } return await readLocalFallbackFavicon(preferDark); } catch (error) { console.error("[favicon] Failed to proxy favicon:", error); const preferDark = new URL(request.url).searchParams.get("theme") === "dark"; return await readLocalFallbackFavicon(preferDark); } }