Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
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)
|
||
}
|
||
}
|