ai-chatbot/lib/storage/original-image-download.ts

71 lines
3.6 KiB
TypeScript

import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
/** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
target = new URL(task?.targetUrl || source);
}
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
target.host = base.host;
target.protocol = base.protocol;
}
if (base.protocol !== "https:" || target.origin !== base.origin ||
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
// A Kodo download token signs the complete URL; adding attname would invalidate it.
if (target.searchParams.has("token")) return null;
// Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, {
method: "HEAD", cache: "default",
signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]),
}).catch(async (error) => {
signal.throwIfAborted();
if (target.search) throw error;
const { createS3Client, toDbS3UploadConfig } = await import("./service");
const storage = toDbS3UploadConfig(config);
if (!storage) throw error;
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
if (!key) throw error;
const client = createS3Client(storage);
try {
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
});
return new Response(null, { headers: {
"content-type": object.ContentType || "",
"content-length": String(object.ContentLength ?? 0),
} });
} catch (storageError) {
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
throw error;
} finally {
client.destroy();
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
if (!extension) return null;
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
const filename = `${basename}.${extension}`;
target.searchParams.set("attname", filename);
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
bytes: Number(response.headers.get("content-length")) || 0 };
}