chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

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()
}
}