停用旧图片存储并更新主题 Logo
s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。 提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。 验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
This commit is contained in:
parent
348cf4cf5d
commit
1daf08a964
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -316,7 +317,12 @@ export default function GalleryPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||||
{images.map((img) => (
|
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
||||||
|
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||||
|
<p>{RETIRED_STORAGE_MESSAGE}</p>
|
||||||
|
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||||||
@ -325,7 +331,7 @@ export default function GalleryPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<Image
|
<Image
|
||||||
src={img.thumbnailUrl || img.url}
|
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
||||||
alt={img.alt}
|
alt={img.alt}
|
||||||
width={200}
|
width={200}
|
||||||
height={270}
|
height={270}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
|
|
||||||
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
"tripo3d.ai",
|
"tripo3d.ai",
|
||||||
"tripo3d.com",
|
"tripo3d.com",
|
||||||
"worldlabs.ai",
|
"worldlabs.ai",
|
||||||
"s3.1080p.hk",
|
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
"scqny.jiansun.vip",
|
"scqny.jiansun.vip",
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
|||||||
|
|
||||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||||
"bbyy.ai",
|
"bbyy.ai",
|
||||||
"s3.1080p.hk",
|
|
||||||
"img.yydd.ai",
|
"img.yydd.ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const rawUrl = String(searchParams.get("url") || "").trim();
|
const rawUrl = String(searchParams.get("url") || "").trim();
|
||||||
|
if (isRetiredStorageUrl(rawUrl)) {
|
||||||
|
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { after, NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
@ -83,7 +84,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const urls = resolveDownloadUrls(body, requestOrigin);
|
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||||
|
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||||
|
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
|
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { and, eq, desc, sql } from "drizzle-orm";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
||||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||||
|
|
||||||
const FEATURED_TIMEOUT_MS = 1800;
|
const FEATURED_TIMEOUT_MS = 1800;
|
||||||
@ -63,7 +64,10 @@ export async function GET(request: NextRequest) {
|
|||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||||
.where(eq(generatedImage.isFeatured, true))
|
.where(and(
|
||||||
|
eq(generatedImage.isFeatured, true),
|
||||||
|
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
||||||
|
))
|
||||||
.orderBy(desc(generatedImage.featuredAt))
|
.orderBy(desc(generatedImage.featuredAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@ -74,7 +78,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return {
|
return {
|
||||||
images: featuredImages.map((img) => ({
|
images: featuredImages.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
thumbnailUrl: img.thumbnailUrl,
|
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
||||||
originalUrl: img.originalUrl,
|
originalUrl: img.originalUrl,
|
||||||
imageSize: img.imageSize,
|
imageSize: img.imageSize,
|
||||||
prompt: img.prompt,
|
prompt: img.prompt,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -266,6 +267,9 @@ function redirectToDirectImage(target: URL): NextResponse {
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
|
if (isRetiredStorageUrl(rawUrl)) {
|
||||||
|
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
|
}
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -663,12 +664,12 @@ async function readScopedSiteSettings(keys: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||||
return {
|
return resolveSiteBranding({
|
||||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||||
siteLogo: settings?.siteLogo || null,
|
siteLogo: String(settings?.siteLogo || ""),
|
||||||
siteLogoDark: settings?.siteLogoDark || null,
|
siteLogoDark: String(settings?.siteLogoDark || ""),
|
||||||
favicon: settings?.favicon || null,
|
favicon: String(settings?.favicon || ""),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getScopedPublicSiteSettings(
|
async function getScopedPublicSiteSettings(
|
||||||
@ -1541,7 +1542,7 @@ export async function GET(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...baseSettings,
|
...resolveSiteBranding(baseSettings),
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
baseSettings.lovartPromoText ||
|
baseSettings.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -8439,7 +8440,11 @@ export function ImageNode({
|
|||||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||||
{data.src ? (
|
{data.src ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{!shouldRequestDisplayImage ? (
|
{isRetiredStorageUrl(data.src) ? (
|
||||||
|
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{RETIRED_STORAGE_MESSAGE}
|
||||||
|
</div>
|
||||||
|
) : !shouldRequestDisplayImage ? (
|
||||||
<ImageNodeDeferredLoadState />
|
<ImageNodeDeferredLoadState />
|
||||||
) : isCompareMode && hasCompareSource ? (
|
) : isCompareMode && hasCompareSource ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
type HomeImageProps = {
|
type HomeImageProps = {
|
||||||
sources: Array<string | null | undefined>;
|
sources: Array<string | null | undefined>;
|
||||||
@ -28,7 +29,7 @@ function imageCandidates(source: string) {
|
|||||||
/** Keep failed image elements out of view, trying each distinct source once. */
|
/** Keep failed image elements out of view, trying each distinct source once. */
|
||||||
export function HomeImage(props: HomeImageProps) {
|
export function HomeImage(props: HomeImageProps) {
|
||||||
const sources = [...new Set(props.sources
|
const sources = [...new Set(props.sources
|
||||||
.filter((url): url is string => Boolean(url?.trim()))
|
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
|
||||||
.flatMap(imageCandidates))];
|
.flatMap(imageCandidates))];
|
||||||
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,9 +51,9 @@ export function LovartFeaturedGallery({
|
|||||||
const [retryVersion, setRetryVersion] = useState(0);
|
const [retryVersion, setRetryVersion] = useState(0);
|
||||||
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
|
||||||
const visibleItems = uniqueItems.filter((item) =>
|
const visibleItems = uniqueItems.filter((item) =>
|
||||||
Boolean(item.thumbnailUrl || item.originalUrl) && !unavailable.has(imageIdentity(item)),
|
Boolean(item.thumbnailUrl || item.originalUrl),
|
||||||
);
|
);
|
||||||
const unavailableCount = uniqueItems.length - visibleItems.length;
|
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={featuredSectionRef}
|
ref={featuredSectionRef}
|
||||||
@ -102,7 +102,7 @@ export function LovartFeaturedGallery({
|
|||||||
|
|
||||||
{unavailableCount > 0 && (
|
{unavailableCount > 0 && (
|
||||||
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
|
||||||
<span>{unavailableCount} 张灵感图片暂时无法加载,已收起。</span>
|
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
|
||||||
@ -127,18 +127,21 @@ export function LovartFeaturedGallery({
|
|||||||
<button
|
<button
|
||||||
key={`${imageIdentity(item)}:${retryVersion}`}
|
key={`${imageIdentity(item)}:${retryVersion}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={unavailable.has(imageIdentity(item))}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||||
}
|
}
|
||||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||||
>
|
>
|
||||||
<HomeImage
|
{unavailable.has(imageIdentity(item)) ? (
|
||||||
|
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
|
||||||
|
) : <HomeImage
|
||||||
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
|
||||||
alt={item.prompt || "灵感图"}
|
alt={item.prompt || "灵感图"}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
|
||||||
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||||
/>
|
/>}
|
||||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||||
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
@ -306,6 +307,10 @@ export function ImagePreview({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
|
||||||
|
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 缩略图(显示压缩后的缩略图)*/}
|
{/* 缩略图(显示压缩后的缩略图)*/}
|
||||||
|
|||||||
33
docs/retired-storage.md
Normal file
33
docs/retired-storage.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 旧存储停用与七牛上传核查
|
||||||
|
|
||||||
|
## s3.1080p.hk
|
||||||
|
|
||||||
|
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
|
||||||
|
|
||||||
|
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
|
||||||
|
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
|
||||||
|
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
|
||||||
|
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
|
||||||
|
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
|
||||||
|
|
||||||
|
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
|
||||||
|
|
||||||
|
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
|
||||||
|
|
||||||
|
## 2026-10-14 上传峰值
|
||||||
|
|
||||||
|
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
|
||||||
|
|
||||||
|
| 范围 | 对象数 | 字节数 | GB |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
|
||||||
|
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
|
||||||
|
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
|
||||||
|
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
|
||||||
|
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
|
||||||
|
|
||||||
|
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
|
||||||
|
|
||||||
|
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
|
||||||
|
|
||||||
|
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
|
||||||
@ -296,6 +297,9 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<CanvasDownloadAssetResult> {
|
}): Promise<CanvasDownloadAssetResult> {
|
||||||
|
if (isRetiredStorageUrl(params.file.url)) {
|
||||||
|
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
|
||||||
|
}
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
||||||
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -4744,9 +4745,9 @@ export async function getSiteSettings() {
|
|||||||
? videoCanvasEnabledSetting.value !== false
|
? videoCanvasEnabledSetting.value !== false
|
||||||
: videoCanvasSidebarEnabledSetting?.value !== false;
|
: videoCanvasSidebarEnabledSetting?.value !== false;
|
||||||
|
|
||||||
return { ...(settings || defaultSettings), videoCanvasEnabled };
|
return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return defaultSettings;
|
return resolveSiteBranding(defaultSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
lib/site-branding.ts
Normal file
13
lib/site-branding.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { isRetiredStorageUrl } from '@/lib/storage/retired-storage';
|
||||||
|
|
||||||
|
export const DEFAULT_SITE_LOGO = '/brand/logo-black.png';
|
||||||
|
export const DEFAULT_SITE_LOGO_DARK = '/brand/logo-white.svg';
|
||||||
|
|
||||||
|
export function resolveSiteBranding<T extends { siteLogo?: string | null; siteLogoDark?: string | null; favicon?: string | null }>(settings: T) {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
siteLogo: !settings.siteLogo || isRetiredStorageUrl(settings.siteLogo) ? DEFAULT_SITE_LOGO : settings.siteLogo,
|
||||||
|
siteLogoDark: !settings.siteLogoDark || isRetiredStorageUrl(settings.siteLogoDark) ? DEFAULT_SITE_LOGO_DARK : settings.siteLogoDark,
|
||||||
|
favicon: !settings.favicon || isRetiredStorageUrl(settings.favicon) ? DEFAULT_SITE_LOGO : settings.favicon,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } from "./retired-storage";
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -38,7 +39,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)) return null;
|
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { isGeneratedImageSource } from "./image-archive-urls";
|
import { isGeneratedImageSource } from "./image-archive-urls";
|
||||||
@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
||||||
|
if (isRetiredStorageUrl(params.sourceUrl)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
@ -81,6 +82,7 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
return data.buffer.length > 0;
|
return data.buffer.length > 0;
|
||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
||||||
|
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||||
if (url.startsWith("data:")) {
|
if (url.startsWith("data:")) {
|
||||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl } 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";
|
||||||
@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
|||||||
config &&
|
config &&
|
||||||
config.type === "s3" &&
|
config.type === "s3" &&
|
||||||
config.isEnabled &&
|
config.isEnabled &&
|
||||||
|
!isRetiredStorageUrl(config.endpoint) &&
|
||||||
|
!isRetiredStorageUrl(config.publicUrl) &&
|
||||||
String(config.endpoint || "").trim() &&
|
String(config.endpoint || "").trim() &&
|
||||||
String(config.bucket || "").trim() &&
|
String(config.bucket || "").trim() &&
|
||||||
String(config.accessKeyId || "").trim() &&
|
String(config.accessKeyId || "").trim() &&
|
||||||
@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
16
lib/storage/retired-storage.ts
Normal file
16
lib/storage/retired-storage.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/** Permanently decommissioned storage. Historical records remain for reference. */
|
||||||
|
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
|
||||||
|
|
||||||
|
export function isRetiredStorageUrl(value: string | null | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value, 'https://local.invalid');
|
||||||
|
if (url.hostname === 's3.1080p.hk') return true;
|
||||||
|
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
|
||||||
|
const source = url.searchParams.get('url');
|
||||||
|
return Boolean(source && new URL(source).hostname === 's3.1080p.hk');
|
||||||
|
}
|
||||||
|
} catch { /* Relative local assets and non-URL values are not retired storage. */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { isRetiredStorageUrl, 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";
|
||||||
@ -149,6 +150,9 @@ 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)) {
|
||||||
|
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||||
|
}
|
||||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||||||
const isCloudS3 =
|
const isCloudS3 =
|
||||||
@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
|||||||
!value ||
|
!value ||
|
||||||
!provider ||
|
!provider ||
|
||||||
!value.isEnabled ||
|
!value.isEnabled ||
|
||||||
|
isRetiredStorageUrl(endpoint) ||
|
||||||
|
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||||
!endpoint ||
|
!endpoint ||
|
||||||
!bucket ||
|
!bucket ||
|
||||||
!accessKeyId ||
|
!accessKeyId ||
|
||||||
@ -364,7 +370,7 @@ 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 && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(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",
|
||||||
|
|||||||
BIN
public/brand/logo-black.png
Normal file
BIN
public/brand/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
1
public/brand/logo-white.svg
Normal file
1
public/brand/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
29
scripts/storage/retire-1080p-storage.cjs
Normal file
29
scripts/storage/retire-1080p-storage.cjs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
require('node:module').createRequire(require.resolve('next/package.json'))('@next/env').loadEnvConfig(process.cwd());
|
||||||
|
const sql = require('postgres')(process.env.POSTGRES_URL, { max: 1, prepare: false });
|
||||||
|
const pattern = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const result = await sql.begin(async tx => {
|
||||||
|
const storage = await tx`select id,"isEnabled","isDefault" from "StorageConfig" where endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}`;
|
||||||
|
const featured = await tx`select id,"isFeatured" from "GeneratedImage" where "isFeatured" and "originalUrl" ~* ${pattern}`;
|
||||||
|
const branding = await tx`select id,"siteLogo","siteLogoDark",favicon from "SiteSettings" where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern}`;
|
||||||
|
if (!apply) return { apply, storage: storage.length, featured: featured.length, branding: branding.length };
|
||||||
|
const directory = process.env.STORAGE_RETIREMENT_BACKUP_DIR || '/var/lib/ai-chatbot/retired-storage-20261103';
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const backup = path.join(directory, `before-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(backup, JSON.stringify({ storage, featured, branding }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||||
|
const disabled = await tx`update "StorageConfig" set "isEnabled"=false,"isDefault"=false,"updatedAt"=now() where (endpoint ~* ${pattern} or "publicUrl" ~* ${pattern}) and ("isEnabled" or "isDefault") returning id`;
|
||||||
|
const unfeatured = await tx`update "GeneratedImage" set "isFeatured"=false where "isFeatured" and "originalUrl" ~* ${pattern} returning id`;
|
||||||
|
const branded = await tx`update "SiteSettings" set
|
||||||
|
"siteLogo"=case when "siteLogo" ~* ${pattern} then '/brand/logo-black.png' else "siteLogo" end,
|
||||||
|
"siteLogoDark"=case when "siteLogoDark" ~* ${pattern} then '/brand/logo-white.svg' else "siteLogoDark" end,
|
||||||
|
favicon=case when favicon ~* ${pattern} then '/brand/logo-black.png' else favicon end
|
||||||
|
where "siteLogo" ~* ${pattern} or "siteLogoDark" ~* ${pattern} or favicon ~* ${pattern} returning id`;
|
||||||
|
return { apply, backup, disabled: disabled.length, unfeatured: unfeatured.length, branded: branded.length };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.message); process.exitCode = 1; }).finally(() => sql.end());
|
||||||
2
tests/retired-storage.config.ts
Normal file
2
tests/retired-storage.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
|
||||||
76
tests/routes/retired-storage.test.ts
Normal file
76
tests/routes/retired-storage.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { serverLoader } from '../helpers/canvas-download-server-loader';
|
||||||
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from '@/lib/storage/retired-storage';
|
||||||
|
import { resolveSiteBranding, DEFAULT_SITE_LOGO, DEFAULT_SITE_LOGO_DARK } from '@/lib/site-branding';
|
||||||
|
|
||||||
|
const retired = 'https://s3.1080p.hk/jsxiangche/2025/01/image.png';
|
||||||
|
const current = 'https://scqnycdn.jiansun.vip/uploads/current.png';
|
||||||
|
|
||||||
|
test('retired URLs include stored proxy URLs but do not include live storage', () => {
|
||||||
|
for (const url of [retired, retired.replace('https:', 'http:'), '/api/image-proxy?url=' + encodeURIComponent(retired)]) {
|
||||||
|
expect(isRetiredStorageUrl(url)).toBe(true);
|
||||||
|
}
|
||||||
|
for (const url of [current, '/brand/logo-black.png', 'https://s3.jiansun.vip/gongxiangpan/a.png', 'https://example.com/s3.1080p.hk/a.png']) {
|
||||||
|
expect(isRetiredStorageUrl(url)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('original image proxy returns permanent failure without redirecting or fetching', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw 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'}}));
|
||||||
|
expect(response.status).toBe(410);
|
||||||
|
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byte cache never fetches retired resources', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
||||||
|
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retired sources cannot enter the archive queue', async () => {
|
||||||
|
let inserts = 0;
|
||||||
|
const module = serverLoader({
|
||||||
|
'@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}},
|
||||||
|
'@/lib/db/schema': {},
|
||||||
|
})('lib/storage/image-archive-db.ts');
|
||||||
|
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();
|
||||||
|
expect(inserts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single downloads reject retired sources before async preparation, while live candidates remain usable', async () => {
|
||||||
|
const fetched: string[] = [];
|
||||||
|
const route = serverLoader({
|
||||||
|
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||||
|
'next/server':{NextResponse,after:()=>{}},
|
||||||
|
'@/lib/storage/canvas-cdn-download':{},
|
||||||
|
'@/lib/storage/image-download-preparation':{},
|
||||||
|
'@/lib/canvas/photoshop-safe-download':{
|
||||||
|
fetchCanvasDownloadAsset:async({file}:any)=>{fetched.push(file.url);return {ok:true,bytes:Buffer.from('original'),filename:'original.png',contentType:'image/png'};},
|
||||||
|
sanitizeCanvasDownloadFileName:()=> 'original', buildAttachmentContentDisposition:()=> 'attachment',
|
||||||
|
},
|
||||||
|
})('app/api/canvas/download-image/route.ts');
|
||||||
|
const request = (body:unknown) => new NextRequest('https://ai.yydd.ai/api/canvas/download-image',{method:'POST',body:JSON.stringify(body)});
|
||||||
|
const failure = await route.POST(request({url:retired,preparation:'async',preferDirect:true}));
|
||||||
|
expect(failure.status).toBe(410);
|
||||||
|
expect(fetched).toEqual([]);
|
||||||
|
const success = await route.POST(request({urls:[retired,current]}));
|
||||||
|
expect(success.status).toBe(200);
|
||||||
|
expect(fetched).toEqual([current]);
|
||||||
|
expect(await success.text()).toBe('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ZIP asset preparation reports permanent failure without retrying the source', async () => {
|
||||||
|
const module = serverLoader({})( 'lib/canvas/photoshop-safe-download.ts');
|
||||||
|
const result = await module.fetchCanvasDownloadAsset({file:{url:retired},index:0,requestOrigin:'https://ai.yydd.ai',cookie:''});
|
||||||
|
expect(result).toMatchObject({ok:false,error:RETIRED_STORAGE_MESSAGE});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('branding replaces unavailable storage with theme variants and preserves custom live branding', () => {
|
||||||
|
expect(resolveSiteBranding({siteLogo:retired,siteLogoDark:retired,favicon:retired})).toEqual({siteLogo:DEFAULT_SITE_LOGO,siteLogoDark:DEFAULT_SITE_LOGO_DARK,favicon:DEFAULT_SITE_LOGO});
|
||||||
|
expect(resolveSiteBranding({siteLogo:current,siteLogoDark:current}).siteLogo).toBe(current);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user