diff --git a/lib/storage/original-image-download.ts b/lib/storage/original-image-download.ts index 564d7e386..a75dfd194 100644 --- a/lib/storage/original-image-download.ts +++ b/lib/storage/original-image-download.ts @@ -4,6 +4,7 @@ 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) { @@ -28,8 +29,32 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s // 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: "no-store", - signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), + 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") || ""; diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts index f6d85e2eb..3188af83b 100644 --- a/tests/routes/original-image-download.test.ts +++ b/tests/routes/original-image-download.test.ts @@ -10,7 +10,8 @@ test("original downloads use the archived JPEG rather than the prepared PNG and "./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: { downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" }, } }) }, - fetch: async (url: URL, options: { method: string }) => { + fetch: async (url: URL, options: { method: string; cache: string }) => { + expect(options.cache).toBe("default"); calls.push({ url: url.toString(), method: options.method }); return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }), arrayBuffer() { throw new Error("Image bytes must bypass the application"); } }; @@ -52,6 +53,28 @@ test("temporary third-party URLs are never checked with HEAD when no archive is expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull(); }); +test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => { + let closed = false; + const module = serverLoader({ + "@/lib/db/queries": { getDefaultStorageConfig: async () => config }, + "./image-archive-db": {}, + "./service": { + toDbS3UploadConfig: () => ({ bucket: "images" }), + createS3Client: () => ({ + send: async (command: { input: { Bucket: string; Key: string } }) => { + expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" }); + return { ContentType: "image/jpeg", ContentLength: 12345 }; + }, + destroy: () => { closed = true; }, + }), + }, + fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); }, + })("lib/storage/original-image-download.ts"); + const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal); + expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 }); + expect(closed).toBe(true); +}); + test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => { const route = serverLoader({ "next/server": { NextResponse: Response },