diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 6e38a1e18..6c1cac4ac 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 裁剪与调整尺寸的跨域原图读取 + +- 已修复:裁剪、调整尺寸和宫格切分共用原图读取入口,优先复用缓存或 `browser-process` 授权的资源 Blob;无缓存时以 CORS fetch 读取字节并构造可导出的 Blob URL,操作结束释放临时 URL。 +- 已修复:资源 Blob 读取失败时直接保留错误,不再退回展示地址;HTTP 图片加载在设置 src 前启用 CORS,覆盖同源资源接口重定向到 OSS/CDN 的情况。 +- [x] `image-raster.node.test.ts` 7 项专项测试通过,覆盖 CORS 设置顺序、模拟重定向的裁剪/缩放/切分导出、缓存与无缓存原图、读取失败不降级、HTTP 错误及 Blob URL 释放。测试使用模拟 Image/Canvas,不替代真实浏览器 CORS 验收。 +- [ ] 使用本地上传图片及刷新后恢复的云端图片,分别执行裁剪、三种插值方式的调整尺寸和宫格切分,确认输出尺寸、原图保留和结果保存。 +- [ ] 验证资源接口重定向及 OSS/CDN CORS 配置;允许跨域读取时应成功导出,拒绝读取或授权失效时应明确失败。尚未进行完整前端类型检查、构建或真实站点联调。 + ## 系统模型「拉取模型」弹窗搜索 - 已实现:后台「模型与资源 → 系统模型」点「拉取模型」后的选择弹窗新增搜索框(按模型标识做大小写不敏感的子串匹配,自动聚焦、可一键清空),因为部分上游端点会返回几百个模型。 diff --git a/web/src/lib/image-raster.ts b/web/src/lib/image-raster.ts index 11f1471ea..41ed63176 100644 --- a/web/src/lib/image-raster.ts +++ b/web/src/lib/image-raster.ts @@ -1,3 +1,17 @@ +export async function prepareRasterSource(source: string, readBlob?: () => Promise): Promise<{ url: string; release: () => void }> { + if (!source.trim()) throw new Error("图片地址为空"); + if (source.startsWith("data:") || source.startsWith("blob:")) return { url: source, release: () => {} }; + let blob = await readBlob?.(); + if (!blob) { + // fetch validates CORS across redirects; a display-only image URL must never be drawn as a fallback. + const response = await fetch(source, { mode: "cors", credentials: "same-origin" }); + if (!response.ok) throw new Error(`原图读取失败(${response.status})`); + blob = await response.blob(); + } + const url = URL.createObjectURL(blob); + return { url, release: () => URL.revokeObjectURL(url) }; +} + export function loadRasterImage(source: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const picture = new Image(); @@ -25,7 +39,8 @@ export function loadRasterImage(source: string, signal?: AbortSignal): Promise { if (!node.metadata?.content) return; - // 云端图片地址通常不带 CORS 头,直接画到 canvas 会被判定为跨域而无法导出。 - // 优先用本地缓存里的 Blob 构造同源地址,裁剪才能读取像素。 let releaseSource = () => {}; try { - const source = await resolveCroppableImageSource(node); + const source = await resolveProcessableImageSource(node); releaseSource = source.release; const cropped = await cropDataUrl(source.url, crop); const image = await uploadImage(cropped); @@ -647,8 +646,11 @@ export function useCanvasMediaTools({ const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => { if (!node.metadata?.content || !isValidGridSplit(params)) return; + let releaseSource = () => {}; try { - const pieces = await splitDataUrl(node.metadata.content, params); + const source = await resolveProcessableImageSource(node); + releaseSource = source.release; + const pieces = await splitDataUrl(source.url, params); const sizedPieces = await Promise.all(pieces.map(async (piece) => { const image = await uploadImage(piece.dataUrl); return { piece, image, size: fitNodeSize(image.width, image.height) }; @@ -675,6 +677,8 @@ export function useCanvasMediaTools({ message.success(`已切分为 ${childNodes.length} 个子节点`); } catch (error) { message.error(error instanceof Error ? `切分失败:${error.message}` : "图片切分失败,请重试"); + } finally { + releaseSource(); } }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); @@ -1507,8 +1511,11 @@ export function useCanvasMediaTools({ const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { if (!node.metadata?.content) return; setUpscaleNodeId(null); + let releaseSource = () => {}; try { - const upscaled = await upscaleDataUrl(node.metadata.content, params); + const source = await resolveProcessableImageSource(node); + releaseSource = source.release; + const upscaled = await upscaleDataUrl(source.url, params); const image = await uploadImage(upscaled); const size = fitNodeSize(image.width, image.height); const childId = nanoid(); @@ -1520,6 +1527,8 @@ export function useCanvasMediaTools({ await persistMediaNodes([child]); } catch (error) { message.error(error instanceof Error ? `放大失败:${error.message}` : "图片放大失败,请重试"); + } finally { + releaseSource(); } }, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]); @@ -1747,17 +1756,11 @@ export function useCanvasMediaTools({ }; } -// 裁剪、切分等像素级操作要求图片同源可读:云端地址若不带 CORS 头, -// canvas 会被标记为跨域,toDataURL 直接抛 SecurityError。 -// 这里优先用本地缓存 Blob 构造同源 objectURL,取不到时再回退原始地址。 -async function resolveCroppableImageSource(node: CanvasNodeData): Promise<{ url: string; release: () => void }> { +// 资源 Blob 读取使用 browser-process 授权,复用缓存;读取失败时保留错误,不退回展示地址。 +async function resolveProcessableImageSource(node: CanvasNodeData): Promise<{ url: string; release: () => void }> { const content = node.metadata?.content ?? ""; - if (content.startsWith("data:") || content.startsWith("blob:")) return { url: content, release: () => {} }; const storageKey = node.metadata?.storageKey; - if (!storageKey) return { url: content, release: () => {} }; + if (!storageKey) return prepareRasterSource(content); const readBlob = storageKey.startsWith("image:") || storageKey.startsWith("generation-image:") ? getImageBlob : getMediaBlob; - const blob = await readBlob(storageKey).catch(() => null); - if (!blob) return { url: content, release: () => {} }; - const url = URL.createObjectURL(blob); - return { url, release: () => URL.revokeObjectURL(url) }; + return prepareRasterSource(content, () => readBlob(storageKey)); } diff --git a/web/test/image-raster.node.test.ts b/web/test/image-raster.node.test.ts new file mode 100644 index 000000000..2cbde1558 --- /dev/null +++ b/web/test/image-raster.node.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loadRasterImage, prepareRasterSource } from "../src/lib/image-raster"; +import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../src/lib/canvas/canvas-image-data"; + +test("same-origin resource redirects request CORS before assigning src", async () => { + const originalImage = globalThis.Image; + const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location"); + const requests: Array<{ src: string; crossOrigin: string }> = []; + try { + Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } }); + globalThis.Image = class { + crossOrigin = ""; + naturalWidth = 940; + naturalHeight = 1674; + onload?: () => void; + onerror?: () => void; + set src(src: string) { + requests.push({ src, crossOrigin: this.crossOrigin }); + queueMicrotask(() => this.onload?.()); + } + } as unknown as typeof Image; + for (const src of ["/api/resources/image/file", "https://cdn.example/image.png", "data:image/png;base64,test", "blob:local-image"]) { + await loadRasterImage(src); + } + assert.deepEqual(requests.map((request) => request.crossOrigin), ["anonymous", "anonymous", "", ""]); + } finally { + globalThis.Image = originalImage; + if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation); + else Reflect.deleteProperty(globalThis, "location"); + } +}); + +test("crop, resize and split export a redirected resource without taint", async () => { + const originalImage = globalThis.Image; + const originalDocument = globalThis.document; + const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location"); + const outputs: Array<{ width: number; height: number }> = []; + try { + Object.defineProperty(globalThis, "location", { configurable: true, value: { href: "https://canvas.example/", origin: "https://canvas.example" } }); + globalThis.Image = class { + crossOrigin = ""; + naturalWidth = 940; + naturalHeight = 1674; + tainted = false; + onload?: () => void; + onerror?: () => void; + set src(_src: string) { + // Model a resource endpoint redirecting to a different image origin. + this.tainted = this.crossOrigin !== "anonymous"; + queueMicrotask(() => this.onload?.()); + } + } as unknown as typeof Image; + globalThis.document = { + createElement() { + let tainted = false; + return { + width: 0, + height: 0, + getContext() { return { drawImage(image: { tainted: boolean }) { tainted = image.tainted; } }; }, + toDataURL() { + if (tainted) throw new DOMException("Tainted canvases may not be exported", "SecurityError"); + outputs.push({ width: this.width, height: this.height }); + return "data:image/png;base64,output"; + }, + }; + }, + } as unknown as Document; + const source = "/api/resources/image/file"; + assert.match(await cropDataUrl(source, { x: 0, y: 0, width: 0.5, height: 0.5 }), /^data:image\/png/); + assert.match(await upscaleDataUrl(source, { targetLongEdge: 2048, algorithm: "high" }), /^data:image\/png/); + assert.equal((await splitDataUrl(source, { rows: 2, columns: 2 })).length, 4); + assert.deepEqual(outputs[0], { width: 470, height: 837 }); + assert.deepEqual(outputs[1], { width: 1150, height: 2048 }); + } finally { + globalThis.Image = originalImage; + globalThis.document = originalDocument; + if (originalLocation) Object.defineProperty(globalThis, "location", originalLocation); + else Reflect.deleteProperty(globalThis, "location"); + } +}); + +test("readable local sources skip remote resource reads", async () => { + for (const url of ["data:image/png;base64,local", "blob:local-image"]) { + const source = await prepareRasterSource(url, async () => { throw new Error("must not read remote bytes"); }); + assert.equal(source.url, url); + source.release(); + } +}); + +test("cached resource bytes use a Blob URL and release it", async () => { + const blob = new Blob(["pixels"], { type: "image/png" }); + const source = await prepareRasterSource("/api/resources/image/file", async () => blob); + assert.match(source.url, /^blob:/); + assert.equal(await (await fetch(source.url)).text(), "pixels"); + source.release(); + await assert.rejects(fetch(source.url)); +}); + +test("resource read failures propagate without falling back to the display URL", async () => { + const originalFetch = globalThis.fetch; + let fetched = false; + const failure = new Error("resource authorization failed"); + try { + globalThis.fetch = async () => { fetched = true; throw new Error("unsafe fallback"); }; + await assert.rejects(prepareRasterSource("/api/resources/image/file", async () => { throw failure; }), (error) => error === failure); + assert.equal(fetched, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("uncached sources fetch with CORS and create a readable Blob URL", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: unknown; options: unknown }> = []; + try { + globalThis.fetch = async (url, options) => { + requests.push({ url, options }); + return new Response(new Blob(["pixels"], { type: "image/png" })); + }; + const source = await prepareRasterSource("/api/resources/image/file", async () => null); + assert.deepEqual(requests, [{ url: "/api/resources/image/file", options: { mode: "cors", credentials: "same-origin" } }]); + assert.match(source.url, /^blob:/); + source.release(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("HTTP and CORS failures stop processing instead of returning an unsafe URL", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => new Response("forbidden", { status: 403 }); + await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), /原图读取失败(403)/); + const failure = new TypeError("Failed to fetch"); + globalThis.fetch = async () => { throw failure; }; + await assert.rejects(prepareRasterSource("https://cdn.example/image.png"), (error) => error === failure); + await assert.rejects(prepareRasterSource(" "), /图片地址为空/); + } finally { + globalThis.fetch = originalFetch; + } +});