import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation"; import { after, NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { buildAttachmentContentDisposition, fetchCanvasDownloadAsset, sanitizeCanvasDownloadFileName, } from "@/lib/canvas/photoshop-safe-download"; export const runtime = "nodejs"; export const maxDuration = 120; async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) { // A proxy candidate for the same original must not repeat the same slow // upstream request through our own server (and create another HTTP timeout). const sources = [...new Set(input.urls.map(url => { const source = extractImageProxySourceUrl(url) || url; return inferOriginalImageUrl(source) || source; }))]; for (let attempt = 0; attempt < 3; attempt++) { signal.throwIfAborted(); for (const url of sources) { signal.throwIfAborted(); const result = await fetchCanvasDownloadAsset({ file: { url, name: "original" }, index: 0, requestOrigin: input.requestOrigin, cookie: input.cookie, includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal, }); if (result.ok) return { buffer: result.bytes, contentType: result.contentType }; } // Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset. // No AI/model request is involved and a completed source fetch is shared. if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000)); } throw new Error("Original image sources unavailable"); } function getRequestOrigin(request: NextRequest): string { const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); if (forwardedHost && forwardedProto) { return `${forwardedProto}://${forwardedHost}`; } return new URL(request.url).origin; } function resolveDownloadUrl(rawUrl: unknown, origin: string): string { const value = String(rawUrl || "").trim(); if (!value) return ""; try { const parsed = new URL(value, origin); if (!/^https?:$/i.test(parsed.protocol)) return ""; return parsed.toString(); } catch (_error) { return ""; } } function resolveDownloadUrls(body: Record, origin: string): string[] { const rawCandidates = Array.isArray(body.urls) ? body.urls : [body.url]; const urls: string[] = []; const seen = new Set(); for (const rawUrl of rawCandidates) { const url = resolveDownloadUrl(rawUrl, origin); if (!url || seen.has(url)) continue; seen.add(url); urls.push(url); } return urls.slice(0, 6); } export async function POST(request: NextRequest) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = (await request.json().catch(() => ({}))) as Record; const requestOrigin = getRequestOrigin(request); const candidates = resolveDownloadUrls(body, requestOrigin); if (candidates.length && candidates.every(isRetiredStorageUrl)) { return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 }); } const urls = candidates.filter(url => !isRetiredStorageUrl(url)); const requestedUrls = [...urls]; if (urls.length === 0) { return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 }); } const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image"); if (body.preferDirect === true) { const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null })); if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } }); if (task?.targetUrl) { if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl); after(() => prepareImageArchiveDownload(task.id)); } } const cookie = request.headers.get("cookie") || ""; if (body.preparation === "async") { const result = await getDownloadPreparationQueue(prepareDownload, after).request({ userId: session.user.id, urls: requestedUrls, requestOrigin, cookie, }); if (result.status === "preparing") { return NextResponse.json({ delivery: "preparing", retryAfter: 3, message: "正在准备原图,完成后将自动下载" }, { status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" }, }); } if (result.status === "failed") { return NextResponse.json({ success: false, error: result.error }, { status: 502 }); } return new NextResponse(new Uint8Array(result.asset.buffer), { headers: { "Content-Type": result.asset.contentType, "Content-Length": String(result.asset.buffer.length), "Content-Disposition": buildAttachmentContentDisposition(`${name}.png`), "Cache-Control": "no-store", } }); } let result: Awaited> | null = null; const failures: string[] = []; for (const url of urls) { result = await fetchCanvasDownloadAsset({ file: { url, name }, index: 0, requestOrigin, cookie, includeSequencePrefix: false, }); if (result.ok) break; failures.push(`${url}: ${result.error}`); } if (!result?.ok) { if (failures.length > 0) { console.warn("[CanvasDownloadImage] All candidates failed:", failures); } return NextResponse.json( { success: false, error: result?.error || "图片源文件暂时不可访问" }, { status: 502 } ); } return new NextResponse(result.bytes, { status: 200, headers: { "Content-Type": result.contentType, "Content-Length": String(result.bytes.length), "Content-Disposition": buildAttachmentContentDisposition(result.filename), "Cache-Control": "no-store", }, }); } catch (error) { console.error("[CanvasDownloadImage] Failed:", error); return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 }); } }