From f058012bb88177ecb54de73fdaec9ddf5b7a9414 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 12 Nov 2026 16:20:56 +0800 Subject: [PATCH] Complete direct Qiniu media upload, display and download paths --- app/api/image-proxy/route.ts | 13 +- .../canvas/LiblibVideoCanvasReplica.tsx | 3 +- .../director/DirectorReconstructionPanel.tsx | 5 +- .../canvas/director/DirectorStudioDialog.tsx | 10 +- lib/image-delivery.ts | 24 ++++ lib/storage/canvas-cdn-download.ts | 29 +++-- lib/storage/image-archive-db.ts | 11 ++ lib/storage/qiniu-image-archive.ts | 14 ++- next.config.ts | 5 +- tests/qiniu-delivery.config.ts | 9 ++ tests/routes/image-archive-flow.test.ts | 2 +- tests/routes/qiniu-direct-paths.test.ts | 115 ++++++++++++++++++ 12 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 tests/qiniu-delivery.config.ts create mode 100644 tests/routes/qiniu-direct-paths.test.ts diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index 40a14685f..29d2e8cf9 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -331,7 +331,18 @@ export async function GET(request: NextRequest) { ); } - if (!original && isAlibabaModelImageUrl(target.toString())) { + if (!original && !forceProxy && isBrowserImageRequest(request)) { + const direct = buildImageProxyUrl(target.toString(), { + width: width || undefined, quality: directQuality || undefined, + format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined, + }); + if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) && + new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) { + return redirectToDirectImage(new URL(direct)); + } + } + + if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) { try { const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db"); const mappings = await getArchivedImageUrlMappings([target.toString()]); diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 58f6a79d5..bb26b031a 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1,4 +1,5 @@ "use client"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { GroupStoryboardPreview } from "./GroupStoryboardPreview"; @@ -15104,7 +15105,7 @@ function DirectorNodeComposer({ if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;} setBusy(true);setError("");try{ const form=new FormData();form.append("file",file);form.append("purpose","director-studio"); - const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)}); + const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)}); const result=await response.json();const url=result.url||result.data?.url; if(!response.ok||!url)throw new Error(result.error||"图片上传失败"); onDraft({directorReferenceUrl:String(url)}); diff --git a/components/canvas/director/DirectorReconstructionPanel.tsx b/components/canvas/director/DirectorReconstructionPanel.tsx index 64007e758..07d81ea33 100644 --- a/components/canvas/director/DirectorReconstructionPanel.tsx +++ b/components/canvas/director/DirectorReconstructionPanel.tsx @@ -1,4 +1,5 @@ "use client"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { useState } from "react"; import { toast } from "sonner"; import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene"; @@ -64,9 +65,7 @@ export function DirectorReconstructionPanel({ const form = new FormData(); form.append("file", file); form.append("purpose", "director-studio"); - const response = await fetch("/api/files/upload", { - method: "POST", - body: form, + const response = await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000), }); const data = await response.json(); diff --git a/components/canvas/director/DirectorStudioDialog.tsx b/components/canvas/director/DirectorStudioDialog.tsx index 00723fcb4..b9b1ad01a 100644 --- a/components/canvas/director/DirectorStudioDialog.tsx +++ b/components/canvas/director/DirectorStudioDialog.tsx @@ -1,4 +1,5 @@ "use client"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { commitDirectorScene, resolveDirectorCamera, @@ -43,14 +44,13 @@ async function upload(file: File) { "purpose", file.type.startsWith("video/") ? "canvas-video-process" : "director-studio", ); - const response = await fetch( - /\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload", - { + const response = /\.glb$/i.test(file.name) + ? await fetch("/api/canvas/director/upload-model", { method: "POST", body: form, signal: AbortSignal.timeout(120000), - }, - ); + }) + : await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) }); const result = await response.json(); const url = result.url || result.data?.url; if (!response.ok || !url) throw new Error(result.error || "上传失败"); diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index bd2a961bf..a830084ca 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -134,6 +134,13 @@ function resolveTrustedDirectStorageDisplayUrl( try { const target = new URL(url); const configuredCdn = getConfiguredCdnUrl(); + const storageOrigin = process.env.NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN; + if (configuredCdn?.protocol === "https:" && storageOrigin && + target.origin === new URL(storageOrigin).origin && + !target.searchParams.has("token") && !target.searchParams.has("X-Amz-Signature")) { + target.host = configuredCdn.host; + target.protocol = configuredCdn.protocol; + } const isConfiguredCdn = target.origin === configuredCdn?.origin; if ( target.hostname !== configuredCdn?.hostname && @@ -215,6 +222,23 @@ function resolveAliyunOssDisplayUrl( export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string { let source = String(url || "").trim(); + if (options.delivery !== "original" && options.delivery !== "proxy") { + const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin; + try { + const saved = new URL(source, base); + if (saved.origin === base && saved.pathname === IMAGE_PROXY_PATH && + !saved.searchParams.has("original") && saved.searchParams.get("proxy") !== "1") { + const nested = saved.searchParams.get("url") || ""; + const format = options.format || saved.searchParams.get("f") as ImageProxyFormat | undefined; + const direct = resolveTrustedDirectStorageDisplayUrl(nested, { + width: options.width || Number(saved.searchParams.get("w")) || undefined, + quality: options.quality || Number(saved.searchParams.get("q")) || undefined, + format: ["webp", "avif", "jpeg", "png"].includes(format || "") ? format : undefined, + }); + if (direct) return direct; + } + } catch { /* Keep non-storage proxies on their existing path. */ } + } // Some messages retain our preview URL instead of its source URL. Rebuild // that local proxy request without the preview's resize/format parameters. if (options.delivery === "original") { diff --git a/lib/storage/canvas-cdn-download.ts b/lib/storage/canvas-cdn-download.ts index ed6b2d03f..b3d698917 100644 --- a/lib/storage/canvas-cdn-download.ts +++ b/lib/storage/canvas-cdn-download.ts @@ -1,6 +1,6 @@ import "server-only"; import { getDefaultStorageConfig } from "@/lib/db/queries"; -import { findImageArchiveDownloadTask } from "./image-archive-db"; +import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; export async function resolveCanvasCdnDownload(urls: string[], filename: string) { @@ -8,17 +8,30 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string) const source = extractImageProxySourceUrl(url) || url; return [source, inferOriginalImageUrl(source) || source]; }))); - const task = await findImageArchiveDownloadTask(sources, true); - const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset; - if (!task) return { task, direct: null, canPrepareDirect: false }; + let task = await findImageArchiveDownloadTask(sources, true); const config = await getDefaultStorageConfig(); if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false }; - const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png"; - const source = ready ? asset.url : task.targetUrl; - if (!source) return { task, direct: null, canPrepareDirect: false }; - const url = new URL(source); const base = new URL(config.publicUrl); const origin = process.env.CANVAS_QINIU_ORIGIN_URL; + if (!task && base.protocol === "https:") { + for (const candidate of sources) { + const stored = new URL(candidate); + if (origin && stored.origin === new URL(origin).origin && !stored.search) { + stored.host = base.host; + stored.protocol = base.protocol; + } + if (stored.origin === base.origin && !stored.search && !stored.hash && + stored.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/uploads/")) { + task = await ensureStoredImageDownloadTask(stored.toString()); + break; + } + } + } + const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset; + const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png"; + const source = ready ? asset.url : task?.targetUrl; + if (!source) return { task, direct: null, canPrepareDirect: false }; + const url = new URL(source); if (origin && url.origin === new URL(origin).origin) url.host = base.host; if (base.protocol !== "https:" || url.origin !== base.origin || !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false }; diff --git a/lib/storage/image-archive-db.ts b/lib/storage/image-archive-db.ts index 3ea06faef..7484f6b52 100644 --- a/lib/storage/image-archive-db.ts +++ b/lib/storage/image-archive-db.ts @@ -353,6 +353,17 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema } return task || null; } + +/** Reuse the download worker for stored uploads and synchronous tool results. */ +export async function ensureStoredImageDownloadTask(url: string) { + const now = new Date(); + const [created] = await db.insert(imageArchiveTask).values({ + sourceUrl: url, targetUrl: url, status: "completed", archiveAfterAt: now, + replaceAfterAt: now, archivedAt: now, replacedAt: now, updatedAt: now, + metadata: { downloadAsset: { status: "pending" } }, + }).onConflictDoNothing({ target: imageArchiveTask.sourceUrl }).returning(); + return created || await findImageArchiveDownloadTask([url], true); +} export async function hasPendingImageDownloads() { const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where( sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`, diff --git a/lib/storage/qiniu-image-archive.ts b/lib/storage/qiniu-image-archive.ts index 2062693fd..431ef7683 100644 --- a/lib/storage/qiniu-image-archive.ts +++ b/lib/storage/qiniu-image-archive.ts @@ -48,7 +48,7 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited value && new URL(value).origin === source.origin) + || source.hostname === "localhost" || source.hostname === "[::1]" + || /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null; // The archive task owns this object key, including retries after a timeout. const pathname = `uploads/ai-generated-archive-${archiveId}`; diff --git a/next.config.ts b/next.config.ts index b198b9ed0..ffd613c12 100644 --- a/next.config.ts +++ b/next.config.ts @@ -49,7 +49,10 @@ function resolveDevMediaOrigins(isDev: boolean) { } const nextConfig: NextConfig = { - env: { NEXT_PUBLIC_APP_RELEASE: appRelease }, + env: { + NEXT_PUBLIC_APP_RELEASE: appRelease, + NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "", + }, distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", poweredByHeader: false, allowedDevOrigins: ["127.0.0.1"], diff --git a/tests/qiniu-delivery.config.ts b/tests/qiniu-delivery.config.ts new file mode 100644 index 000000000..e54d25b67 --- /dev/null +++ b/tests/qiniu-delivery.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./routes", + testMatch: /(?:qiniu-direct-paths|image-delivery-qiniu|image-archive-flow|original-image-download|canvas-download-speed)\.test\.ts/, + workers: 1, + reporter: "list", + timeout: 30_000, +}); diff --git a/tests/routes/image-archive-flow.test.ts b/tests/routes/image-archive-flow.test.ts index 29f1a5ffc..55ece594f 100644 --- a/tests/routes/image-archive-flow.test.ts +++ b/tests/routes/image-archive-flow.test.ts @@ -127,7 +127,7 @@ test("Qiniu archives 98K originals without relaying full image bytes through the expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length }); stored = indexed; expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined(); - expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull(); + expect(await load.archiveImageThroughQiniu("data:image/png;base64,aW1hZ2U=", id)).toBeNull(); expect(cloudCalls).toHaveLength(3); expect(reads).toHaveLength(3); }); diff --git a/tests/routes/qiniu-direct-paths.test.ts b/tests/routes/qiniu-direct-paths.test.ts new file mode 100644 index 000000000..ae1b9bd37 --- /dev/null +++ b/tests/routes/qiniu-direct-paths.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from "@playwright/test"; +import sharp from "sharp"; +import { NextRequest, NextResponse } from "next/server"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; + +const config = { type: "qiniu-kodo", provider: "qiniu-kodo", isEnabled: true, + publicUrl: "https://cdn.example", endpoint: "https://s3.example", region: "cn-east-1", + bucket: "images", accessKeyId: "test-ak", secretAccessKey: "test-sk" }; + +test("external model results including signed OSS URLs are fetched by Qiniu without app image reads", async () => { + let source = ""; + let calls = 0; + const load = serverLoader({ + "@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }), + buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}` }, + fetch: async (url: string, init: RequestInit) => { + calls++; + expect(new URL(url).hostname).toBe("iovip-z0.qiniuio.com"); + expect(init.method).toBe("POST"); + const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0]; + expect(Buffer.from(encoded, "base64url").toString()).toBe(source); + return Response.json({ key: "uploads/ai-generated-archive-task-1", fsize: 5000, mimeType: "image/jpeg" }); + }, + }, { NEXT_PUBLIC_APP_URL: "https://app.example" }); + const { archiveImageThroughQiniu } = load("lib/storage/qiniu-image-archive.ts"); + for (source of ["https://api.vip98k.com/api/external/assets/image", + "https://dashscope-a717.oss-accelerate.aliyuncs.com/result.png?Expires=123&Signature=a%2Bb%3D", + "https://images.provider.example/result.jpg"]) { + expect(await archiveImageThroughQiniu(source, "task-1")).toMatchObject({ bytes: 5000, contentType: "image/jpeg" }); + } + for (const local of ["https://cdn.example/uploads/a.png", "https://app.example/api/generated-image-preview/id", + "http://127.0.0.1/a.png", "data:image/png;base64,abcd"]) { + expect(await archiveImageThroughQiniu(local, "task-1")).toBeNull(); + } + expect(calls).toBe(3); +}); + +test("stored direct uploads prepare once and then download from CDN", async () => { + let task: any = null; + const ensured: string[] = []; + const url = "https://cdn.example/uploads/direct/user-1/image.webp"; + const module = serverLoader({ + "@/lib/db/queries": { getDefaultStorageConfig: async () => config }, + "./image-archive-db": { + findImageArchiveDownloadTask: async () => task, + ensureStoredImageDownloadTask: async (stored: string) => { + ensured.push(stored); return task = { id: "stored", targetUrl: stored, metadata: { downloadAsset: { status: "pending" } } }; + }, + }, + })("lib/storage/canvas-cdn-download.ts"); + const pending = await module.resolveCanvasCdnDownload([url], "图片.png"); + expect(pending).toMatchObject({ direct: null, canPrepareDirect: true }); + task.metadata.downloadAsset = { status: "ready", url: "https://cdn.example/uploads/converted.png", contentType: "image/png", bytes: 1234 }; + const ready = await module.resolveCanvasCdnDownload([url], "图片.png"); + expect(ready.direct).toMatchObject({ delivery: "cdn", bytes: 1234 }); + expect(new URL(ready.direct.downloadUrl).searchParams.get("attname")).toBe("图片.png"); + expect(ensured).toEqual([url]); +}); + +test("download preparation only registers unsigned files in the configured bucket", async () => { + const stored: string[] = []; + const module = serverLoader({ + "@/lib/db/queries": { getDefaultStorageConfig: async () => config }, + "./image-archive-db": { findImageArchiveDownloadTask: async () => null, + ensureStoredImageDownloadTask: async (url: string) => { stored.push(url); return { id: "1", targetUrl: url }; } }, + }, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" })("lib/storage/canvas-cdn-download.ts"); + for (const url of ["https://other.example/uploads/a.png", "https://cdn.example/uploads/a.png?token=signed", + "https://cdn.example/uploads/a.png?imageView2/2/w/160", "https://cdn.example/private/a.png"]) { + expect((await module.resolveCanvasCdnDownload([url], "a.png")).canPrepareDirect).toBe(false); + } + expect((await module.resolveCanvasCdnDownload(["https://origin.example/uploads/direct/user/a.png"], "a.png")).canPrepareDirect).toBe(true); + expect(stored).toEqual(["https://cdn.example/uploads/direct/user/a.png"]); +}); + +test("nested uploaded PNGs use a bounded header read without stripping their colour profile", async () => { + const bytes = await sharp({ create: { width: 32, height: 24, channels: 4, background: "#1d785c80" } }).withIccProfile("p3").png().toBuffer(); + let reads = 0; + const module = serverLoader({ + "@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }), + buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`, + createS3Client: () => ({ destroy() {}, send: async (cmd: any) => { + reads++; expect(cmd.input.Key).toBe("uploads/direct/user/a.png"); + expect(cmd.input.Range).toBe("bytes=0-65535"); + return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: bytes.length, + ContentRange: `bytes 0-${bytes.length - 1}/${bytes.length}`, Body: { transformToByteArray: async () => bytes } }; + } }) }, + })("lib/storage/qiniu-image-archive.ts"); + const url = "https://cdn.example/uploads/direct/user/a.png"; + expect(await module.getReadyQiniuImageDownload(url)).toMatchObject({ status: "ready", url, bytes: bytes.length }); + expect(reads).toBe(1); +}); + +test("preview aliases use the configured CDN and retain processing and signed URL semantics", async () => { + const module = serverLoader({}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example", + NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: "https://origin.example" })("lib/image-delivery.ts"); + const source = "https://origin.example/uploads/a.png"; + const options = { width: 331, quality: 86, format: "webp" }; + const direct = "https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88"; + expect(module.buildImageProxyUrl(source, options)).toBe(direct); + expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`)).toBe(direct); + expect(module.buildImageProxyUrl(source + "?token=signed", options)).toBe(source + "?token=signed"); + expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&proxy=1&w=331`)).toContain("proxy=1"); +}); + +test("saved preview URLs redirect to an archived CDN thumbnail without reading the upstream image", async () => { + const source = "https://images.provider.example/result.png"; + const route = serverLoader({ + "next/server": { NextResponse }, + "@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://cdn.example/uploads/a.png" }) }, + fetch: () => { throw Error("No image bytes should pass through the app"); }, + }, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example" })("app/api/image-proxy/route.ts"); + const response = await route.GET(new NextRequest(`https://app.example/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`, { headers: { "sec-fetch-dest": "image" } })); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88"); +});