修复阿里模型结果预览 403 和重复读取大图 #39
@ -3,6 +3,7 @@ import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_M
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
const logger = createLogger("image.download");
|
const logger = createLogger("image.download");
|
||||||
@ -242,6 +243,7 @@ function resolveDirectImageTarget(
|
|||||||
requestedQuality: number | null,
|
requestedQuality: number | null,
|
||||||
requestedFormat: string | null
|
requestedFormat: string | null
|
||||||
): URL | null {
|
): URL | null {
|
||||||
|
if (isAlibabaModelImageUrl(target.toString())) return null;
|
||||||
if (!isBrowserImageRequest(request)) {
|
if (!isBrowserImageRequest(request)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -336,6 +338,25 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||||
|
try {
|
||||||
|
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
|
||||||
|
const mappings = await getArchivedImageUrlMappings([target.toString()]);
|
||||||
|
const archived = mappings[target.toString()];
|
||||||
|
if (archived) {
|
||||||
|
const preview = buildImageProxyUrl(archived, {
|
||||||
|
width: width || undefined,
|
||||||
|
quality,
|
||||||
|
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
|
||||||
|
});
|
||||||
|
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
|
||||||
|
target = new URL(archived);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("preview_archive_lookup_failed", error, { requestId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (original && searchParams.get("download") === "resolve") {
|
if (original && searchParams.get("download") === "resolve") {
|
||||||
try {
|
try {
|
||||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
||||||
@ -388,7 +409,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fetchSignal = AbortSignal.any([
|
const fetchSignal = AbortSignal.any([
|
||||||
request.signal,
|
request.signal,
|
||||||
AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||||
]);
|
]);
|
||||||
const upstreamFailure = (error: unknown) => {
|
const upstreamFailure = (error: unknown) => {
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||||
@ -399,37 +420,51 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: timedOut ? 504 : 502 },
|
{ status: timedOut ? 504 : 502 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let upstreamResponse: Response;
|
let contentType: string;
|
||||||
try {
|
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
|
||||||
signal: fetchSignal,
|
|
||||||
headers: {
|
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
|
||||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
||||||
if (original) return downloadFailure("Source is not an image");
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalBuffer: Buffer;
|
let originalBuffer: Buffer;
|
||||||
try {
|
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
try {
|
||||||
} catch (error) {
|
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
||||||
return upstreamFailure(error);
|
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
|
||||||
|
originalBuffer = data.buffer;
|
||||||
|
contentType = data.contentType;
|
||||||
|
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let upstreamResponse: Response;
|
||||||
|
try {
|
||||||
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
|
signal: fetchSignal,
|
||||||
|
headers: {
|
||||||
|
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamResponse.ok) {
|
||||||
|
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
|
if (original) return downloadFailure("Source is not an image");
|
||||||
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (original) {
|
if (original) {
|
||||||
return buildImageResponse(originalBuffer, {
|
return buildImageResponse(originalBuffer, {
|
||||||
|
|||||||
@ -6,8 +6,9 @@ const IMAGE_PROXY_PATH = "/api/image-proxy";
|
|||||||
export function buildImageFallbackUrl(value: string | null | undefined): string {
|
export function buildImageFallbackUrl(value: string | null | undefined): string {
|
||||||
const source = value || "";
|
const source = value || "";
|
||||||
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
||||||
if (!legacy) return source;
|
const modelImage = isAlibabaModelImageUrl(source);
|
||||||
const proxy = buildImageProxyUrl(legacy);
|
if (!legacy && !modelImage) return source;
|
||||||
|
const proxy = buildImageProxyUrl(legacy || source, modelImage ? { width: 960, quality: 86, format: "webp" } : {});
|
||||||
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
|
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +91,15 @@ function isAliyunOssImageHost(hostname: string): boolean {
|
|||||||
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
|
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Model result buckets allow downloading objects but deny OSS image processing. */
|
||||||
|
export function isAlibabaModelImageUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return /^dashscope(?:-[a-z0-9-]+)?\.oss-[a-z0-9-]+\.aliyuncs\.com$/i.test(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isTrustedDirectStorageImageHost(hostname: string): boolean {
|
function isTrustedDirectStorageImageHost(hostname: string): boolean {
|
||||||
const normalized = hostname.trim().toLowerCase();
|
const normalized = hostname.trim().toLowerCase();
|
||||||
let configuredCdnHost = "";
|
let configuredCdnHost = "";
|
||||||
@ -231,7 +241,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return trustedDirectStorageUrl;
|
return trustedDirectStorageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source)) {
|
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) {
|
||||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||||
if (directDisplayUrl) {
|
if (directDisplayUrl) {
|
||||||
return directDisplayUrl;
|
return directDisplayUrl;
|
||||||
|
|||||||
@ -3,8 +3,61 @@ import { NextRequest } from "next/server";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { GET } from "@/app/api/image-proxy/route";
|
import { GET } from "@/app/api/image-proxy/route";
|
||||||
import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
test.describe("Qiniu image delivery", () => {
|
test.describe("Qiniu image delivery", () => {
|
||||||
|
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
|
||||||
|
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
|
||||||
|
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");
|
||||||
|
expect(preview.pathname).toBe("/api/image-proxy");
|
||||||
|
expect(preview.searchParams.get("url")).toBe(source);
|
||||||
|
expect(preview.searchParams.has("x-oss-process")).toBe(false);
|
||||||
|
expect(buildImageProxyUrl("https://other-bucket.oss-cn-shanghai.aliyuncs.com/image.png", { width: 960 })).toContain("x-oss-process=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a completed archive serves old model-result references from Qiniu immediately", async () => {
|
||||||
|
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/archived.png";
|
||||||
|
const route = serverLoader({
|
||||||
|
"next/server": { NextResponse },
|
||||||
|
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://yybb.top/archived.png" }) },
|
||||||
|
fetch: () => { throw new Error("Must use the completed archive"); },
|
||||||
|
})("app/api/image-proxy/route.ts");
|
||||||
|
const request = new URL("https://app.example/api/image-proxy");
|
||||||
|
request.searchParams.set("url", source); request.searchParams.set("w", "960"); request.searchParams.set("f", "webp");
|
||||||
|
const response = await route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe("https://yybb.top/archived.png?imageView2/2/w/960/format/webp/q/82");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unarchived model previews share one original fetch with archiving and return resized WebP", async () => {
|
||||||
|
const source = `https://dashscope-a717.oss-accelerate.aliyuncs.com/${crypto.randomUUID()}.png`;
|
||||||
|
const original = await sharp({ create: { width: 1024, height: 1024, channels: 3, background: "#345678" } }).png().toBuffer();
|
||||||
|
let requests = 0;
|
||||||
|
const load = serverLoader({
|
||||||
|
"next/server": { NextResponse },
|
||||||
|
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({}) },
|
||||||
|
fetch: async (url: string) => {
|
||||||
|
expect(url).toBe(source); requests++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
return new Response(original, { headers: { "content-type": "image/png" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const route = load("app/api/image-proxy/route.ts");
|
||||||
|
const archiveRead = load("lib/storage/image-byte-cache.ts").fetchImageBytes(source);
|
||||||
|
const responses = await Promise.all([100, 200].map(width => {
|
||||||
|
const request = new URL("https://app.example/api/image-proxy");
|
||||||
|
request.searchParams.set("url", source); request.searchParams.set("w", String(width)); request.searchParams.set("f", "webp");
|
||||||
|
return route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
|
||||||
|
}));
|
||||||
|
expect((await archiveRead).buffer.equals(original)).toBe(true);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
for (let i = 0; i < responses.length; i++) {
|
||||||
|
expect(responses[i].status).toBe(200);
|
||||||
|
expect(responses[i].headers.get("content-type")).toBe("image/webp");
|
||||||
|
expect((await sharp(Buffer.from(await responses[i].arrayBuffer())).metadata()).width).toBe([100, 200][i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
|
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
|
||||||
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
|
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
|
||||||
expect(size.displayWidth).toBeCloseTo(839.8);
|
expect(size.displayWidth).toBeCloseTo(839.8);
|
||||||
|
|||||||
@ -38,7 +38,7 @@ test("legacy display URLs go through archive resolution while other Alibaba buck
|
|||||||
const url = buildImageProxyUrl(legacy, { width: 400 });
|
const url = buildImageProxyUrl(legacy, { width: 400 });
|
||||||
expect(url).toMatch(/^\/api\/image-proxy\?/);
|
expect(url).toMatch(/^\/api\/image-proxy\?/);
|
||||||
expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy);
|
expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy);
|
||||||
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toContain("x-oss-process=");
|
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toMatch(/^\/api\/image-proxy\?/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
|
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user