// Favicon API - 优先返回受控远端品牌图,任何异常都使用镜像内置真实图标。 import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' import { normalizeBrandLogoUrl } from '@/lib/brand-assets' import { MAX_BRAND_IMAGE_BYTES, validateBrandImage } from '@/lib/brand-image-validation' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' const FAVICON_PATH = join(process.cwd(), 'public', 'brand', 'favicon.png') const FETCH_TIMEOUT_MS = 5_000 const INITIAL_REMOTE_BUFFER_BYTES = 64 * 1024 const MAX_REMOTE_FAVICON_BYTES = Math.min(MAX_BRAND_IMAGE_BYTES, 1024 * 1024) const SUCCESS_CACHE_TTL_MS = 10 * 60 * 1000 const FAILURE_CACHE_TTL_MS = 30 * 1000 const FAVICON_HEADERS = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', } type RemoteFaviconPayload = { bytes: Buffer contentType: string } let successCache: (RemoteFaviconPayload & { url: string; expiresAt: number }) | null = null let failureCache: { url: string; expiresAt: number } | null = null let latestRequestedUrl = '' const inFlightRequests = new Map>() async function localFaviconResponse() { const favicon = await readFile(FAVICON_PATH) return new NextResponse(Uint8Array.from(favicon).buffer, { headers: FAVICON_HEADERS }) } function declaresOversizedBody(contentLength: string | null) { const normalized = contentLength?.trim() if (!normalized || !/^\d+$/.test(normalized)) return false const parsed = Number(normalized) return !Number.isSafeInteger(parsed) || parsed > MAX_REMOTE_FAVICON_BYTES } async function cancelBody(body: ReadableStream | null) { if (!body) return try { await body.cancel('favicon response exceeds size limit') } catch { // 连接可能已经由 fetch/AbortController 关闭,无需掩盖本地回退。 } } async function readRemoteBodyWithLimit(response: Response) { if (declaresOversizedBody(response.headers.get('content-length'))) { await cancelBody(response.body) return null } const reader = response.body?.getReader() if (!reader) return null let bytesRead = 0 let output = Buffer.allocUnsafe( Math.min(INITIAL_REMOTE_BUFFER_BYTES, MAX_REMOTE_FAVICON_BYTES), ) try { while (true) { const { done, value } = await reader.read() if (done) break if (!value?.byteLength) continue const nextBytesRead = bytesRead + value.byteLength if (nextBytesRead > MAX_REMOTE_FAVICON_BYTES) { try { await reader.cancel('favicon response exceeds size limit') } catch { // cancel 失败也不得继续读取或保留超限内容。 } return null } if (nextBytesRead > output.length) { const nextCapacity = Math.min( MAX_REMOTE_FAVICON_BYTES, Math.max(nextBytesRead, output.length * 2), ) const expanded = Buffer.allocUnsafe(nextCapacity) output.copy(expanded, 0, 0, bytesRead) output = expanded } Buffer.from(value.buffer, value.byteOffset, value.byteLength).copy( output, bytesRead, ) bytesRead = nextBytesRead } } finally { reader.releaseLock() } if (bytesRead === 0) return null return Buffer.from(output.subarray(0, bytesRead)) } async function fetchRemoteFavicon(url: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) try { const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; FaviconFetcher/2.0)', }, cache: 'no-store', signal: controller.signal, }) const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() if (!response.ok || !contentType?.startsWith('image/')) { await cancelBody(response.body) return null } const bytes = await readRemoteBodyWithLimit(response) if (!bytes) return null let validated try { validated = await validateBrandImage(bytes, contentType) } catch { return null } return { bytes, contentType: validated.contentType } } finally { clearTimeout(timeout) } } async function getRemoteFavicon(url: string): Promise { const now = Date.now() latestRequestedUrl = url if (successCache?.url === url && successCache.expiresAt > now) { return successCache } if (failureCache?.url === url && failureCache.expiresAt > now) { return null } const existingRequest = inFlightRequests.get(url) if (existingRequest) return existingRequest const request = fetchRemoteFavicon(url) .then((payload) => { if (latestRequestedUrl !== url) return payload if (payload) { successCache = { ...payload, url, expiresAt: Date.now() + SUCCESS_CACHE_TTL_MS } failureCache = null } else { failureCache = { url, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS } } return payload }) .catch((error) => { if (latestRequestedUrl === url) { failureCache = { url, expiresAt: Date.now() + FAILURE_CACHE_TTL_MS } } throw error }) .finally(() => { inFlightRequests.delete(url) }) inFlightRequests.set(url, request) return request } function remoteFaviconResponse(payload: RemoteFaviconPayload) { return new NextResponse(Uint8Array.from(payload.bytes).buffer, { headers: { 'Content-Type': payload.contentType, 'Cache-Control': 'public, max-age=3600', }, }) } export async function GET(request: NextRequest) { try { const settings = await prisma.systemSettings.findFirst({ select: { headerLogoUrl: true }, }) const canonicalLogoUrl = normalizeBrandLogoUrl(settings?.headerLogoUrl) if (canonicalLogoUrl?.startsWith('https://')) { const remoteUrl = rewriteStorageUrlForClient(canonicalLogoUrl, request) || canonicalLogoUrl try { const remotePayload = await getRemoteFavicon(remoteUrl) if (remotePayload) return remoteFaviconResponse(remotePayload) } catch (error) { console.warn('[Favicon API] 远端品牌图不可用,改用内置图标:', error) } } return await localFaviconResponse() } catch (error) { console.error('[Favicon API] Error:', error) return localFaviconResponse() } }