194 lines
7.9 KiB
TypeScript
194 lines
7.9 KiB
TypeScript
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
|
import { randomUUID } from "node:crypto";
|
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
|
import { createLogger } from "@/lib/observability/logger";
|
|
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;
|
|
const logger = createLogger("canvas.download");
|
|
|
|
function failed(failure: DownloadFailure, requestId: string) {
|
|
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
|
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
|
status: failure.status,
|
|
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
|
});
|
|
}
|
|
|
|
function preparing(requestId: string) {
|
|
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
|
message: "正在准备原图,完成后将自动开始下载" }, {
|
|
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
|
|
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;
|
|
}))];
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 2; 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: 60_000, retryCount: 1, signal,
|
|
});
|
|
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
|
lastError = result.error;
|
|
}
|
|
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
|
// No AI/model request is involved and a completed source fetch is shared.
|
|
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
else break;
|
|
}
|
|
throw new Error(String(lastError || "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<string, unknown>, origin: string): string[] {
|
|
const rawCandidates = Array.isArray(body.urls) ? body.urls : [body.url];
|
|
const urls: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
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) {
|
|
const requestId = randomUUID();
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return failed(getDownloadFailure("未登录"), requestId);
|
|
}
|
|
|
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
const requestOrigin = getRequestOrigin(request);
|
|
const candidates = resolveDownloadUrls(body, requestOrigin);
|
|
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
|
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
|
}
|
|
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
|
const requestedUrls = [...urls];
|
|
if (urls.length === 0) {
|
|
return failed(getDownloadFailure("图片地址无效"), requestId);
|
|
}
|
|
|
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
|
if (body.preferDirect === true) {
|
|
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
|
logger.warn("cdn_lookup_failed", error, { requestId });
|
|
return { task: null, direct: null, canPrepareDirect: false };
|
|
});
|
|
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
|
if (task && canPrepareDirect && body.preparation === "async") {
|
|
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
|
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
|
return failed(asset.failure || getDownloadFailure(null), task.id);
|
|
}
|
|
scheduleImageDownloadPreparation(task.id);
|
|
return preparing(task.id);
|
|
}
|
|
if (task?.targetUrl) {
|
|
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
|
scheduleImageDownloadPreparation(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 preparing(result.requestId);
|
|
}
|
|
if (result.status === "failed") {
|
|
return failed(result.failure, result.requestId);
|
|
}
|
|
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<ReturnType<typeof fetchCanvasDownloadAsset>> | 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(result.error);
|
|
}
|
|
|
|
if (!result?.ok) {
|
|
if (failures.length > 0) {
|
|
logger.warn("all_sources_failed", { requestId, failures });
|
|
}
|
|
return failed(getDownloadFailure(result?.error), requestId);
|
|
}
|
|
|
|
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) {
|
|
logger.error("download_failed", error, { requestId });
|
|
return failed(getDownloadFailure(error), requestId);
|
|
}
|
|
}
|