Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
216 lines
6.5 KiB
TypeScript
216 lines
6.5 KiB
TypeScript
// 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<string, Promise<RemoteFaviconPayload | null>>()
|
|
|
|
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<Uint8Array> | 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<RemoteFaviconPayload | null> {
|
|
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<RemoteFaviconPayload | null> {
|
|
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()
|
|
}
|
|
}
|