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

240 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { assertProcessableMetadata } from '@/lib/image-processor'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
const ALLOWED_IMAGE_HOST_SUFFIXES = [
'alicdn.com',
'taobaocdn.com',
'tbcdn.cn',
]
const UPSTREAM_TIMEOUT_MS = 10_000
const MAX_UPSTREAM_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_OUTPUT_IMAGE_BYTES = 50 * 1024 * 1024
const MAX_IMAGE_PIXELS = 24_000_000
const MAX_IMAGE_WIDTH = 12_000
const MAX_IMAGE_HEIGHT = 20_000
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
])
class ImageProxyInputError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: number,
) {
super(message)
this.name = 'ImageProxyInputError'
}
}
function isAllowedImageHost(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/\.$/, '')
return ALLOWED_IMAGE_HOST_SUFFIXES.some(
(suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`),
)
}
function validateTargetUrl(rawUrl: string): URL {
let targetUrl: URL
try {
targetUrl = new URL(rawUrl)
} catch {
throw new ImageProxyInputError('INVALID_URL', '图片地址不正确', 400)
}
if (
targetUrl.protocol !== 'https:'
|| targetUrl.username
|| targetUrl.password
|| (targetUrl.port && targetUrl.port !== '443')
|| !isAllowedImageHost(targetUrl.hostname)
) {
throw new ImageProxyInputError('IMAGE_HOST_NOT_ALLOWED', '图片来源不允许导出', 400)
}
return targetUrl
}
async function cancelResponseBody(response: Response, reason?: unknown): Promise<void> {
if (!response.body || response.body.locked) return
await response.body.cancel(reason).catch(() => undefined)
}
function parseContentLength(response: Response): number | null {
const raw = response.headers.get('content-length')
if (raw === null) return null
if (!/^\d+$/.test(raw)) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_SIZE_INVALID', '上游图片大小信息无效', 502)
}
const contentLength = Number(raw)
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_SIZE_INVALID', '上游图片大小信息无效', 502)
}
if (contentLength > MAX_UPSTREAM_IMAGE_BYTES) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_TOO_LARGE', '上游图片超过安全大小限制', 413)
}
return contentLength
}
async function readBoundedUpstreamImage(response: Response): Promise<Buffer> {
let expectedBytes: number | null
try {
expectedBytes = parseContentLength(response)
} catch (error) {
await cancelResponseBody(response, error)
throw error
}
if (!response.body) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_EMPTY', '上游图片内容为空', 502)
}
// 未声明长度时也只预留固定硬上限;实际读取每一块前再次检查,绝不 concat
// 无界 chunks。声明长度时最终还必须与实际字节数完全一致。
const output = Buffer.allocUnsafe(expectedBytes ?? MAX_UPSTREAM_IMAGE_BYTES)
const reader = response.body.getReader()
let offset = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value?.byteLength) continue
if (offset + value.byteLength > MAX_UPSTREAM_IMAGE_BYTES) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_TOO_LARGE', '上游图片超过安全大小限制', 413)
}
if (expectedBytes !== null && offset + value.byteLength > expectedBytes) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_SIZE_MISMATCH', '上游图片实际大小与声明不一致', 502)
}
output.set(value, offset)
offset += value.byteLength
}
} catch (error) {
await reader.cancel(error).catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
if (offset <= 0 || (expectedBytes !== null && offset !== expectedBytes)) {
throw new ImageProxyInputError('UPSTREAM_IMAGE_SIZE_MISMATCH', '上游图片响应不完整', 502)
}
return output.subarray(0, offset)
}
function jsonError(code: string, message: string, status: number) {
return NextResponse.json(
{ success: false, error: { code, message } },
{ status },
)
}
export async function GET(request: NextRequest) {
let timeout: ReturnType<typeof setTimeout> | null = null
let timedOut = false
try {
const session = await getServerSession(authOptions)
if (!session) return jsonError('UNAUTHORIZED', '请先登录后再导出图片', 401)
const rawUrl = request.nextUrl.searchParams.get('url')
if (!rawUrl) return jsonError('MISSING_URL', '缺少图片地址', 400)
const targetUrl = validateTargetUrl(rawUrl)
const { pngBuffer, metadata } = await withImageProcessingSlot(async () => {
// 连下载 Buffer 也纳入整机唯一门闩;否则多个代理请求会在进入 Sharp 前
// 同时各自占用 20MB,跨蓝绿累计仍可能造成内存峰值。
const controller = new AbortController()
timeout = setTimeout(() => {
timedOut = true
controller.abort()
}, UPSTREAM_TIMEOUT_MS)
timeout.unref?.()
const upstream = await fetch(targetUrl.toString(), {
method: 'GET',
cache: 'no-store',
redirect: 'manual',
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 Codex Taobao Image Proxy',
Referer: 'https://item.taobao.com/',
},
})
if (upstream.status >= 300 && upstream.status < 400) {
await cancelResponseBody(upstream, 'redirect rejected')
throw new ImageProxyInputError(
'UPSTREAM_REDIRECT_REJECTED',
'上游图片发生重定向,已为安全起见拒绝',
502,
)
}
if (!upstream.ok) {
await cancelResponseBody(upstream, 'upstream failed')
throw new ImageProxyInputError(
'UPSTREAM_IMAGE_FETCH_FAILED',
`图片下载失败,上游返回 ${upstream.status}`,
502,
)
}
const contentType = String(upstream.headers.get('content-type') || '')
.split(';', 1)[0]
.trim()
.toLowerCase()
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
await cancelResponseBody(upstream, 'content type rejected')
throw new ImageProxyInputError('UPSTREAM_NOT_AN_IMAGE', '上游返回的不是支持的图片', 415)
}
const buffer = await readBoundedUpstreamImage(upstream)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
const inputOptions = {
failOn: 'error' as const,
sequentialRead: true,
limitInputPixels: MAX_IMAGE_PIXELS,
}
const inputMetadata = await sharp(buffer, inputOptions).metadata()
assertProcessableMetadata(inputMetadata, {
maxInputWidth: MAX_IMAGE_WIDTH,
maxInputHeight: MAX_IMAGE_HEIGHT,
maxInputPixels: MAX_IMAGE_PIXELS,
})
const pngBuffer = await sharp(buffer, inputOptions)
.flatten({ background: '#ffffff' })
.png()
.toBuffer()
if (pngBuffer.length > MAX_OUTPUT_IMAGE_BYTES) {
throw new ImageProxyInputError('OUTPUT_IMAGE_TOO_LARGE', '转换后的图片超过安全大小限制', 413)
}
const metadata = await sharp(pngBuffer, inputOptions).metadata()
return { pngBuffer, metadata }
})
return new NextResponse(Uint8Array.from(pngBuffer), {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'private, max-age=300',
'X-Image-Width': String(metadata.width || 0),
'X-Image-Height': String(metadata.height || 0),
},
})
} catch (error) {
if (error instanceof ImageProxyInputError) {
return jsonError(error.code, error.message, error.status)
}
if (timedOut || (error as { name?: string } | undefined)?.name === 'AbortError') {
return jsonError('UPSTREAM_IMAGE_TIMEOUT', '图片下载超时,请稍后再试', 504)
}
console.error('[api/taobao-detail-plus/image-proxy] GET error:', error)
return jsonError('IMAGE_PROXY_ERROR', '图片处理失败,请稍后再试', 500)
} finally {
if (timeout) clearTimeout(timeout)
}
}