Compare commits
7 Commits
codex/imag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b3b5bc78a | |||
| db63f38b9c | |||
| 955ad91ae8 | |||
| 36ac3def8f | |||
| 1d40053c7a | |||
| 07451f5558 | |||
| 13684505a6 |
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||||
|
|
||||||
@ -182,6 +182,18 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
|
||||||
|
try {
|
||||||
|
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
||||||
|
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("HTTP 410")) {
|
||||||
|
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAllowedModelHost(targetUrl.hostname)) {
|
if (!isAllowedModelHost(targetUrl.hostname)) {
|
||||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
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";
|
||||||
@ -298,6 +298,24 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
|
||||||
|
try {
|
||||||
|
if (searchParams.get("original") === "1") {
|
||||||
|
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
||||||
|
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
||||||
|
}
|
||||||
|
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
||||||
|
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
|
||||||
|
} catch (error) {
|
||||||
|
if (searchParams.get("original") === "1") return downloadFailure(error);
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
|
||||||
|
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
|
||||||
|
status: failure.status, headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isBlockedHost(target.hostname)) {
|
if (isBlockedHost(target.hostname)) {
|
||||||
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ import { canvasService } from "@/services/canvas";
|
|||||||
import {
|
import {
|
||||||
inferOriginalImageUrl,
|
inferOriginalImageUrl,
|
||||||
} from "@/lib/image-url";
|
} from "@/lib/image-url";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -15812,8 +15812,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16131,8 +16131,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16208,8 +16208,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16394,8 +16394,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== tag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
|
||||||
event.currentTarget.src = tag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16695,8 +16695,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -17032,8 +17032,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
|
||||||
event.currentTarget.src = url;
|
event.currentTarget.src = buildImageFallbackUrl(url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setFullscreenPreview({ url })}
|
onClick={() => setFullscreenPreview({ url })}
|
||||||
@ -17885,8 +17885,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18210,8 +18210,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18470,8 +18470,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== item.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
|
||||||
event.currentTarget.src = item.url;
|
event.currentTarget.src = buildImageFallbackUrl(item.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18655,8 +18655,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18723,8 +18723,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -19423,8 +19423,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== tagPreview.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
|
||||||
event.currentTarget.src = tagPreview.url;
|
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
||||||
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
||||||
const label = String(match[1] || "");
|
const label = String(match[1] || "");
|
||||||
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
||||||
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
|
|||||||
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
||||||
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
||||||
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
||||||
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
||||||
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
||||||
String(label || "")
|
String(label || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -186,7 +186,7 @@ import {
|
|||||||
normalizeDetailUiSubjectAnalysis,
|
normalizeDetailUiSubjectAnalysis,
|
||||||
} from "@/lib/detail-ui-subject-analysis";
|
} from "@/lib/detail-ui-subject-analysis";
|
||||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
getDefaultThreeDModelAliasId,
|
getDefaultThreeDModelAliasId,
|
||||||
@ -8466,8 +8466,8 @@ export function ImageNode({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== data.src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||||
event.currentTarget.src = data.src;
|
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -8495,8 +8495,8 @@ export function ImageNode({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== compareSource) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||||
event.currentTarget.src = compareSource;
|
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={displayCompareSrc}
|
src={displayCompareSrc}
|
||||||
@ -8563,8 +8563,8 @@ export function ImageNode({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== data.src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||||
event.currentTarget.src = data.src;
|
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
splitOpenClawChartBlocks,
|
splitOpenClawChartBlocks,
|
||||||
} from "@/components/elements/openclaw-chart-card";
|
} from "@/components/elements/openclaw-chart-card";
|
||||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
@ -442,8 +442,8 @@ function MarkdownTableImagePreview({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
||||||
event.currentTarget.src = src;
|
event.currentTarget.src = buildImageFallbackUrl(src);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -473,8 +473,8 @@ function MarkdownTableImagePreview({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== originalSrc) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
||||||
event.currentTarget.src = originalSrc;
|
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
@ -313,7 +313,7 @@ export function ImagePreview({
|
|||||||
event.currentTarget.alt = "Generated Image";
|
event.currentTarget.alt = "Generated Image";
|
||||||
}}
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== (thumbnail || original)) {
|
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) {
|
||||||
event.currentTarget.src = thumbnail || original;
|
event.currentTarget.src = thumbnail || original;
|
||||||
} else if (!compactGrid) {
|
} else if (!compactGrid) {
|
||||||
event.currentTarget.style.aspectRatio = "1";
|
event.currentTarget.style.aspectRatio = "1";
|
||||||
@ -610,7 +610,7 @@ export function ImagePreview({
|
|||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={original}
|
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
|
||||||
alt="Generated Image - Original"
|
alt="Generated Image - Original"
|
||||||
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
||||||
isAnnotationMode ? "cursor-crosshair" : ""
|
isAnnotationMode ? "cursor-crosshair" : ""
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
|
|||||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
import {
|
import {
|
||||||
isAlibabaQwenImage3ModelId,
|
isAlibabaQwenImage3ModelId,
|
||||||
resolveAlibabaImageAsyncEndpoint,
|
resolveAlibabaImageAsyncEndpoint,
|
||||||
@ -129,11 +130,16 @@ async function fetchImageBufferWithRetry(
|
|||||||
if (!match) throw new Error("输入图片数据格式无效");
|
if (!match) throw new Error("输入图片数据格式无效");
|
||||||
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
||||||
}
|
}
|
||||||
|
let sourceUrl = imageUrl;
|
||||||
|
if (normalizeLegacyAliyunStorageUrl(sourceUrl)) {
|
||||||
|
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
||||||
|
sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl);
|
||||||
|
}
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
const ctx = createAbortSignal(timeoutMs);
|
const ctx = createAbortSignal(timeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(imageUrl, {
|
const response = await fetch(sourceUrl, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
import { isProviderBalanceError } from "@/lib/request-error";
|
import { isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -4632,10 +4633,10 @@ function findCanvasFailoverModel(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCanvasImage(
|
export async function generateCanvasImage(
|
||||||
request: CanvasGenerateImageRequest
|
input: CanvasGenerateImageRequest
|
||||||
): Promise<CanvasGenerateImageResult> {
|
): Promise<CanvasGenerateImageResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false };
|
const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input;
|
||||||
const prompt = String(request.prompt || "").trim();
|
const prompt = String(request.prompt || "").trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error("请输入提示词");
|
throw new Error("请输入提示词");
|
||||||
@ -4873,11 +4874,16 @@ export async function generateCanvasImage(
|
|||||||
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
||||||
? request.ownerKey.trim()
|
? request.ownerKey.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const referenceImage =
|
const resolveReference = async (url: string) => {
|
||||||
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
if (!normalizeLegacyAliyunStorageUrl(url)) return url;
|
||||||
const referenceImages = Array.isArray(request.referenceImages)
|
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
||||||
|
return resolveLegacyAliyunStorageUrl(url);
|
||||||
|
};
|
||||||
|
const referenceImage = typeof request.referenceImage === "string"
|
||||||
|
? await resolveReference(request.referenceImage) : undefined;
|
||||||
|
const referenceImages = await Promise.all((Array.isArray(request.referenceImages)
|
||||||
? request.referenceImages
|
? request.referenceImages
|
||||||
: [];
|
: []).map(resolveReference));
|
||||||
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
||||||
const debugMeta =
|
const debugMeta =
|
||||||
request.debugMeta && typeof request.debugMeta === "object"
|
request.debugMeta && typeof request.debugMeta === "object"
|
||||||
|
|||||||
@ -1,5 +1,16 @@
|
|||||||
|
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
|
||||||
|
|
||||||
const IMAGE_PROXY_PATH = "/api/image-proxy";
|
const IMAGE_PROXY_PATH = "/api/image-proxy";
|
||||||
|
|
||||||
|
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
|
||||||
|
export function buildImageFallbackUrl(value: string | null | undefined): string {
|
||||||
|
const source = value || "";
|
||||||
|
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
||||||
|
if (!legacy) return source;
|
||||||
|
const proxy = buildImageProxyUrl(legacy);
|
||||||
|
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
|
type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
|
||||||
|
|
||||||
export type ImageProxyOptions = {
|
export type ImageProxyOptions = {
|
||||||
@ -200,7 +211,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return trustedDirectStorageUrl;
|
return trustedDirectStorageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.delivery !== "proxy") {
|
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source)) {
|
||||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||||
if (directDisplayUrl) {
|
if (directDisplayUrl) {
|
||||||
return directDisplayUrl;
|
return directDisplayUrl;
|
||||||
|
|||||||
@ -243,6 +243,9 @@ export function getRequestFailureDetails(
|
|||||||
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
||||||
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
||||||
}
|
}
|
||||||
|
if (/旧存储已停用|storage_retired/i.test(text)) {
|
||||||
|
return detail("SOURCE_STORAGE_RETIRED", "参考文件所在的旧存储已停用,且没有可用的归档副本。", "请重新上传原始文件后再提交;切换模型或重复提交无法恢复该文件。");
|
||||||
|
}
|
||||||
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||||||
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl } from "./retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -22,6 +22,8 @@ export function getImageArchiveDisplayHoldMs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function imageArchiveSourceKey(source: string) {
|
export function imageArchiveSourceKey(source: string) {
|
||||||
|
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
||||||
|
if (legacy) return legacy;
|
||||||
if (!source.startsWith("data:")) return source;
|
if (!source.startsWith("data:")) return source;
|
||||||
const configured =
|
const configured =
|
||||||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||||
@ -39,7 +41,7 @@ export async function enqueueImageArchiveTask(params: {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||||
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
|
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
@ -312,6 +314,18 @@ export async function getArchivedImageUrlMappings(
|
|||||||
return replacements;
|
return replacements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Legacy OSS is unavailable; only an existing durable copy can serve its URLs. */
|
||||||
|
export async function resolveLegacyAliyunStorageUrl(source: string): Promise<string> {
|
||||||
|
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
||||||
|
if (!legacy) return source;
|
||||||
|
const mappings = await getArchivedImageUrlMappings([source, legacy]);
|
||||||
|
const target = mappings[source] || mappings[legacy];
|
||||||
|
if (!target || normalizeLegacyAliyunStorageUrl(target) || isRetiredStorageUrl(target)) {
|
||||||
|
throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize late writes too: an archive can finish before a task/canvas save arrives.
|
// Normalize late writes too: an archive can finish before a task/canvas save arrives.
|
||||||
export async function resolveArchivedImageUrls<T>(
|
export async function resolveArchivedImageUrls<T>(
|
||||||
value: T,
|
value: T,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// Shared by the server and canvas; no storage or network work happens here.
|
// Shared by the server and canvas; no storage or network work happens here.
|
||||||
export type ArchivedImageUrlMap = Record<string, string>;
|
export type ArchivedImageUrlMap = Record<string, string>;
|
||||||
|
const legacyOssUrlPattern = /https?:\/\/demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/[^\s"'<>`\\)\]}|]+/gi;
|
||||||
|
|
||||||
export function isGeneratedImageSource(value: string) {
|
export function isGeneratedImageSource(value: string) {
|
||||||
return (
|
return (
|
||||||
@ -11,6 +12,7 @@ export function collectImageArchiveSources(value: unknown): string[] {
|
|||||||
const urls = new Set<string>();
|
const urls = new Set<string>();
|
||||||
const visit = (item: unknown) => {
|
const visit = (item: unknown) => {
|
||||||
if (typeof item === "string") {
|
if (typeof item === "string") {
|
||||||
|
for (const match of item.matchAll(legacyOssUrlPattern)) urls.add(match[0].replace(/,+$/, ""));
|
||||||
if (isGeneratedImageSource(item)) urls.add(item);
|
if (isGeneratedImageSource(item)) urls.add(item);
|
||||||
else
|
else
|
||||||
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
|
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
|
||||||
@ -35,6 +37,10 @@ export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImag
|
|||||||
.map((part) => replacements[part] || part)
|
.map((part) => replacements[part] || part)
|
||||||
.join("|||");
|
.join("|||");
|
||||||
return ``;
|
return ``;
|
||||||
|
}).replace(legacyOssUrlPattern, (url) => {
|
||||||
|
if (replacements[url]) return replacements[url];
|
||||||
|
const source = url.replace(/,+$/, "");
|
||||||
|
return (replacements[source] || source) + url.slice(source.length);
|
||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
if (!value || typeof value !== "object" || value instanceof Date) return value;
|
if (!value || typeof value !== "object" || value instanceof Date) return value;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@ -83,13 +83,18 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
||||||
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
let sourceUrl = url;
|
||||||
if (url.startsWith("data:")) {
|
if (normalizeLegacyAliyunStorageUrl(url)) {
|
||||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
const { resolveLegacyAliyunStorageUrl } = await import("./image-archive-db");
|
||||||
|
sourceUrl = await resolveLegacyAliyunStorageUrl(url);
|
||||||
|
}
|
||||||
|
return onceImageBytes(`source:${options.scope || ""}:${sourceUrl}`, async () => {
|
||||||
|
if (sourceUrl.startsWith("data:")) {
|
||||||
|
const match = sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||||
if (!match) throw new Error("Invalid inline generated image");
|
if (!match) throw new Error("Invalid inline generated image");
|
||||||
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
||||||
}
|
}
|
||||||
const response = await fetch(url, {
|
const response = await fetch(sourceUrl, {
|
||||||
headers: options.headers, cache: "no-store", redirect: "follow",
|
headers: options.headers, cache: "no-store", redirect: "follow",
|
||||||
// The deadline covers the body as well as the response headers.
|
// The deadline covers the body as well as the response headers.
|
||||||
signal: options.signal
|
signal: options.signal
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl } from "./retired-storage";
|
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, normalizeLegacyAliyunStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -19,6 +19,7 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
|||||||
config.isEnabled &&
|
config.isEnabled &&
|
||||||
!isRetiredStorageUrl(config.endpoint) &&
|
!isRetiredStorageUrl(config.endpoint) &&
|
||||||
!isRetiredStorageUrl(config.publicUrl) &&
|
!isRetiredStorageUrl(config.publicUrl) &&
|
||||||
|
!isLegacyAliyunStorageConfig(config) &&
|
||||||
String(config.endpoint || "").trim() &&
|
String(config.endpoint || "").trim() &&
|
||||||
String(config.bucket || "").trim() &&
|
String(config.bucket || "").trim() &&
|
||||||
String(config.accessKeyId || "").trim() &&
|
String(config.accessKeyId || "").trim() &&
|
||||||
@ -83,7 +84,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
@ -2,6 +2,29 @@
|
|||||||
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
||||||
|
|
||||||
|
export function normalizeLegacyAliyunStorageUrl(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
let url = new URL(value, 'https://local.invalid');
|
||||||
|
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
|
||||||
|
url = new URL(url.searchParams.get('url') || '');
|
||||||
|
}
|
||||||
|
if (url.hostname !== 'demo-sparkai.oss-cn-shanghai.aliyuncs.com') return null;
|
||||||
|
url.protocol = 'https:';
|
||||||
|
url.hash = '';
|
||||||
|
for (const name of ['Signature', 'OSSAccessKeyId', 'Expires', 'security-token']) url.searchParams.delete(name);
|
||||||
|
url.searchParams.sort();
|
||||||
|
return url.toString();
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyAliyunStorageConfig(config: { bucket?: string | null; endpoint?: string | null; publicUrl?: string | null }): boolean {
|
||||||
|
if (normalizeLegacyAliyunStorageUrl(config.publicUrl)) return true;
|
||||||
|
try {
|
||||||
|
return config.bucket === 'demo-sparkai' && new URL(config.endpoint || '').hostname.endsWith('.aliyuncs.com');
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { put } from "@vercel/blob";
|
import { put } from "@vercel/blob";
|
||||||
@ -150,7 +150,7 @@ export function getContentType(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createS3Client(config: S3UploadConfig): S3Client {
|
export function createS3Client(config: S3UploadConfig): S3Client {
|
||||||
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) {
|
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl) || isLegacyAliyunStorageConfig(config)) {
|
||||||
throw new Error(RETIRED_STORAGE_MESSAGE);
|
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||||
}
|
}
|
||||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||||
@ -343,6 +343,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
|||||||
!value.isEnabled ||
|
!value.isEnabled ||
|
||||||
isRetiredStorageUrl(endpoint) ||
|
isRetiredStorageUrl(endpoint) ||
|
||||||
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||||
|
isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: String(value.publicUrl || "") }) ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
!accessKeyId ||
|
!accessKeyId ||
|
||||||
@ -370,7 +371,8 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
|||||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
||||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
||||||
|
|
||||||
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) &&
|
||||||
|
!isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: process.env.S3_PUBLIC_URL }) && bucket && accessKeyId && secretAccessKey) {
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION?.trim() || "auto",
|
region: process.env.S3_REGION?.trim() || "auto",
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
testMatch: [
|
testMatch: [
|
||||||
|
/routes\/legacy-oss-recovery\.test\.ts/,
|
||||||
|
/routes\/retired-storage\.test\.ts/,
|
||||||
/routes\/original-image-download\.test\.ts/,
|
/routes\/original-image-download\.test\.ts/,
|
||||||
/routes\/canvas-download-speed\.test\.ts/,
|
/routes\/canvas-download-speed\.test\.ts/,
|
||||||
/routes\/canvas-download-recovery\.test\.ts/,
|
/routes\/canvas-download-recovery\.test\.ts/,
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("image metadata containing video or audio paths remains an image", () => {
|
||||||
|
for (const folder of ["video-canvas-demo", "audio-preview"]) {
|
||||||
|
const url = `https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/${folder}/cover.png`;
|
||||||
|
const markdown = ``;
|
||||||
|
expect(extractVideoUrls(markdown)).toEqual([]);
|
||||||
|
expect(extractAudioUrls(markdown)).toEqual([]);
|
||||||
|
expect(extractAssistantImageUrls(markdown)).toContain(url);
|
||||||
|
expect(stripMediaMarkup(markdown)).toBe(markdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
import {
|
import {
|
||||||
collectMessageImageUrls,
|
collectMessageImageUrls,
|
||||||
extractAssistantImageUrls,
|
extractAssistantImageUrls,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ test("known provider and platform failures explain their cause and next action",
|
|||||||
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||||
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||||
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
|
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
|
||||||
|
["HTTP 410: 旧存储已停用,源文件已无法恢复,请重新上传。", "SOURCE_STORAGE_RETIRED", "重新上传"],
|
||||||
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
|
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||||
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
|
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||||
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
||||||
|
|||||||
137
tests/routes/legacy-oss-recovery.test.ts
Normal file
137
tests/routes/legacy-oss-recovery.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
import { normalizeLegacyAliyunStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
import { collectImageArchiveSources, replaceArchivedImageUrls } from "@/lib/storage/image-archive-urls";
|
||||||
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
|
||||||
|
const legacy = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/uploads/product.png";
|
||||||
|
const permanent = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/uploads/product.png";
|
||||||
|
|
||||||
|
test("legacy identity preserves image variants while dropping expired OSS credentials", () => {
|
||||||
|
const signed = legacy.replace("https:", "http:") + "?Signature=expired&Expires=1&OSSAccessKeyId=old&x-oss-process=image%2Fresize%2Cw_100#preview";
|
||||||
|
expect(normalizeLegacyAliyunStorageUrl(signed)).toBe(legacy + "?x-oss-process=image%2Fresize%2Cw_100");
|
||||||
|
expect(normalizeLegacyAliyunStorageUrl("/api/image-proxy?url=" + encodeURIComponent(signed))).toBe(normalizeLegacyAliyunStorageUrl(signed));
|
||||||
|
for (const source of [permanent, "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/image.png", "https://other-bucket.oss-cn-shanghai.aliyuncs.com/a.png"]) {
|
||||||
|
expect(normalizeLegacyAliyunStorageUrl(source)).toBeNull();
|
||||||
|
}
|
||||||
|
expect(isLegacyAliyunStorageConfig({ bucket: "demo-sparkai", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(true);
|
||||||
|
expect(isLegacyAliyunStorageConfig({ bucket: "other-bucket", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("late canvas saves replace legacy URLs embedded in SVG, HTML and Markdown originals", () => {
|
||||||
|
const content = { svg: `<svg><image href="${legacy}"/></svg>`, html: `<img src='${legacy}'>`, text: `` };
|
||||||
|
expect(collectImageArchiveSources(content)).toContain(legacy);
|
||||||
|
const result = replaceArchivedImageUrls(content, { [legacy]: permanent });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("https://demo-sparkai.");
|
||||||
|
expect(result.svg).toContain(permanent);
|
||||||
|
expect(result.text).toBe(``);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy URLs in comma-separated reference descriptions keep sentence punctuation", () => {
|
||||||
|
const text = `Reference: ${legacy}, next reference`;
|
||||||
|
expect(collectImageArchiveSources(text)).toContain(legacy);
|
||||||
|
expect(replaceArchivedImageUrls(text, { [legacy]: permanent })).toBe(`Reference: ${permanent}, next reference`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy display URLs go through archive resolution while other Alibaba buckets keep working", () => {
|
||||||
|
const url = buildImageProxyUrl(legacy, { width: 400 });
|
||||||
|
expect(url).toMatch(/^\/api\/image-proxy\?/);
|
||||||
|
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=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
|
||||||
|
const delivery = serverLoader({ window: { location: { origin: "https://app.example" } } })("lib/image-delivery.ts");
|
||||||
|
const fallback = delivery.buildImageFallbackUrl(legacy);
|
||||||
|
expect(fallback).toMatch(/^https:\/\/app\.example\/api\/image-proxy\?/);
|
||||||
|
expect(delivery.buildImageFallbackUrl(fallback)).toBe(fallback);
|
||||||
|
expect(delivery.buildImageFallbackUrl(permanent)).toBe(permanent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) {
|
||||||
|
return serverLoader({
|
||||||
|
"@/lib/db": { db: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => { throw new Error("Legacy sources must not be queued"); } } },
|
||||||
|
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "source", targetUrl: "target" } },
|
||||||
|
})("lib/storage/image-archive-db.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("archive lookup serves signed legacy links from the durable Qiniu alias", async () => {
|
||||||
|
const archive = archiveLoader([{ sourceUrl: legacy, targetUrl: permanent }]);
|
||||||
|
expect(await archive.resolveLegacyAliyunStorageUrl(legacy + "?Expires=1&Signature=expired")).toBe(permanent);
|
||||||
|
expect(await archive.resolveLegacyAliyunStorageUrl(permanent)).toBe(permanent);
|
||||||
|
expect(await archive.enqueueImageArchiveTask({ sourceUrl: legacy })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy files without a durable copy fail permanently instead of fetching the old bucket", async () => {
|
||||||
|
const archive = archiveLoader([]);
|
||||||
|
await expect(archive.resolveLegacyAliyunStorageUrl(legacy)).rejects.toThrow("HTTP 410");
|
||||||
|
let fetched = 0;
|
||||||
|
const bytes = serverLoader({
|
||||||
|
"./image-archive-db": { resolveLegacyAliyunStorageUrl: archive.resolveLegacyAliyunStorageUrl },
|
||||||
|
fetch: () => { fetched++; throw new Error("Unexpected old-bucket request"); },
|
||||||
|
})("lib/storage/image-byte-cache.ts");
|
||||||
|
await expect(bytes.fetchImageBytes(legacy)).rejects.toThrow(RETIRED_STORAGE_MESSAGE);
|
||||||
|
expect(fetched).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("byte reads use the resolved Qiniu object and preserve its bytes", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const target = permanent + "?acceptance=" + crypto.randomUUID();
|
||||||
|
const original = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1]);
|
||||||
|
const bytes = serverLoader({
|
||||||
|
"./image-archive-db": { resolveLegacyAliyunStorageUrl: async () => target },
|
||||||
|
fetch: async (url: string) => { seen.push(url); return new Response(original, { headers: { "content-type": "image/png" } }); },
|
||||||
|
})("lib/storage/image-byte-cache.ts");
|
||||||
|
expect((await bytes.fetchImageBytes(legacy)).buffer.equals(original)).toBe(true);
|
||||||
|
expect(seen).toEqual([target]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preview requests redirect to Qiniu after resolving a legacy alias", async () => {
|
||||||
|
const route = serverLoader({
|
||||||
|
"next/server": { NextResponse },
|
||||||
|
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
|
||||||
|
fetch: () => { throw new Error("Legacy previews must not fetch the old bucket"); },
|
||||||
|
})("app/api/image-proxy/route.ts");
|
||||||
|
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?url=" + encodeURIComponent(legacy), { headers: { "sec-fetch-dest": "image" } }));
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe(permanent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("original downloads resolve the archived file without reading the legacy source", async () => {
|
||||||
|
const route = serverLoader({
|
||||||
|
"next/server": { NextResponse },
|
||||||
|
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
|
||||||
|
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async (url: string) => {
|
||||||
|
expect(url).toBe(permanent);
|
||||||
|
return { delivery: "cdn", downloadUrl: permanent + "?attname=original.png", filename: "original.png" };
|
||||||
|
} },
|
||||||
|
fetch: () => { throw new Error("Unexpected source request"); },
|
||||||
|
})("app/api/image-proxy/route.ts");
|
||||||
|
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?original=1&download=resolve&url=" + encodeURIComponent(legacy)));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ delivery: "cdn", filename: "original.png" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrecoverable preview and download requests return a cause and recommendation immediately", async () => {
|
||||||
|
let fetched = 0;
|
||||||
|
const route = serverLoader({
|
||||||
|
"next/server": { NextResponse },
|
||||||
|
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => { throw new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE); } },
|
||||||
|
fetch: () => { fetched++; throw new Error("Unexpected source request"); },
|
||||||
|
})("app/api/image-proxy/route.ts");
|
||||||
|
for (const query of ["", "original=1&download=resolve&"]) {
|
||||||
|
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?" + query + "url=" + encodeURIComponent(legacy)));
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(410);
|
||||||
|
expect(data.failure).toMatchObject({ code: "STORAGE_RETIRED", retryable: false });
|
||||||
|
expect(data.failure.recommendation).toContain("重新上传");
|
||||||
|
expect(data.error).toContain(data.requestId);
|
||||||
|
}
|
||||||
|
expect(fetched).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retired model inputs are reported as missing files rather than a model-service failure", async () => {
|
||||||
|
const { getRequestFailureDetails } = await import("@/lib/request-error");
|
||||||
|
expect(getRequestFailureDetails(new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE))).toMatchObject({ code: "SOURCE_STORAGE_RETIRED", outcome: "failed" });
|
||||||
|
});
|
||||||
@ -18,16 +18,18 @@ test('retired URLs include stored proxy URLs but do not include live storage', (
|
|||||||
|
|
||||||
test('original image proxy returns permanent failure without redirecting or fetching', async () => {
|
test('original image proxy returns permanent failure without redirecting or fetching', async () => {
|
||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
|
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw new Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
|
||||||
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
|
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
|
||||||
expect(response.status).toBe(410);
|
expect(response.status).toBe(410);
|
||||||
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
|
const data = await response.json();
|
||||||
|
expect(data.failure).toMatchObject({code:'STORAGE_RETIRED',retryable:false});
|
||||||
|
expect(data.error).toContain(data.requestId);
|
||||||
expect(fetches).toBe(0);
|
expect(fetches).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('byte cache never fetches retired resources', async () => {
|
test('byte cache never fetches retired resources', async () => {
|
||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
const module = serverLoader({ fetch: () => { fetches++; throw new Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
||||||
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
|
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
|
||||||
expect(fetches).toBe(0);
|
expect(fetches).toBe(0);
|
||||||
});
|
});
|
||||||
@ -35,7 +37,7 @@ test('byte cache never fetches retired resources', async () => {
|
|||||||
test('retired sources cannot enter the archive queue', async () => {
|
test('retired sources cannot enter the archive queue', async () => {
|
||||||
let inserts = 0;
|
let inserts = 0;
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
'@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}},
|
'@/lib/db': {db:{insert:()=>{inserts++;throw new Error('Unexpected insert');}}},
|
||||||
'@/lib/db/schema': {},
|
'@/lib/db/schema': {},
|
||||||
})('lib/storage/image-archive-db.ts');
|
})('lib/storage/image-archive-db.ts');
|
||||||
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();
|
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user