- {!shouldRequestDisplayImage ? (
+ {isRetiredStorageUrl(data.src) ? (
+
;
@@ -28,7 +29,7 @@ function imageCandidates(source: string) {
/** Keep failed image elements out of view, trying each distinct source once. */
export function HomeImage(props: HomeImageProps) {
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))];
return
;
}
diff --git a/components/home/lovart/lovart-featured-gallery.tsx b/components/home/lovart/lovart-featured-gallery.tsx
index e2a8387d7..3bdf798fc 100644
--- a/components/home/lovart/lovart-featured-gallery.tsx
+++ b/components/home/lovart/lovart-featured-gallery.tsx
@@ -51,9 +51,9 @@ export function LovartFeaturedGallery({
const [retryVersion, setRetryVersion] = useState(0);
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
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 (
0 && (
- {unavailableCount} 张灵感图片暂时无法加载,已收起。
+ {unavailableCount} 张灵感图片加载失败,请重试。
+ ) : 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]"
- />
+ />}
diff --git a/components/image-preview.tsx b/components/image-preview.tsx
index 597e0ccb9..efd19650c 100644
--- a/components/image-preview.tsx
+++ b/components/image-preview.tsx
@@ -1,4 +1,5 @@
"use client";
+import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react";
@@ -306,6 +307,10 @@ export function ImagePreview({
}
};
+ if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
+ return {RETIRED_STORAGE_MESSAGE}
;
+ }
+
return (
<>
{/* 缩略图(显示压缩后的缩略图)*/}
diff --git a/docs/retired-storage.md b/docs/retired-storage.md
new file mode 100644
index 000000000..a668fc482
--- /dev/null
+++ b/docs/retired-storage.md
@@ -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 的差额。本次未删除或改动七牛对象。
diff --git a/lib/canvas/photoshop-safe-download.ts b/lib/canvas/photoshop-safe-download.ts
index a3edbd61b..f6881c471 100644
--- a/lib/canvas/photoshop-safe-download.ts
+++ b/lib/canvas/photoshop-safe-download.ts
@@ -1,4 +1,5 @@
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 { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
@@ -296,6 +297,9 @@ export async function fetchCanvasDownloadAsset(params: {
retryCount?: number;
signal?: AbortSignal;
}): Promise {
+ if (isRetiredStorageUrl(params.file.url)) {
+ return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
+ }
let lastError = "";
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index 4012d8d18..7700792aa 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { resolveSiteBranding } from "@/lib/site-branding";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import {
@@ -4744,9 +4745,9 @@ export async function getSiteSettings() {
? videoCanvasEnabledSetting.value !== false
: videoCanvasSidebarEnabledSetting?.value !== false;
- return { ...(settings || defaultSettings), videoCanvasEnabled };
+ return resolveSiteBranding({ ...(settings || defaultSettings), videoCanvasEnabled });
} catch (_error) {
- return defaultSettings;
+ return resolveSiteBranding(defaultSettings);
}
}
diff --git a/lib/site-branding.ts b/lib/site-branding.ts
new file mode 100644
index 000000000..6e7be44e9
--- /dev/null
+++ b/lib/site-branding.ts
@@ -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(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,
+ };
+}
diff --git a/lib/storage/image-archive-db.ts b/lib/storage/image-archive-db.ts
index 74391e1e5..432cf3682 100644
--- a/lib/storage/image-archive-db.ts
+++ b/lib/storage/image-archive-db.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { isRetiredStorageUrl } from "./retired-storage";
import { createHash } from "node:crypto";
import {
@@ -38,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record | null;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
- if (!isGeneratedImageSource(sourceUrl)) return null;
+ if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now;
diff --git a/lib/storage/image-archive.ts b/lib/storage/image-archive.ts
index 0a2153a9f..4ac8023c5 100644
--- a/lib/storage/image-archive.ts
+++ b/lib/storage/image-archive.ts
@@ -1,5 +1,6 @@
import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
import "server-only";
+import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { randomUUID } from "node:crypto";
import { isGeneratedImageSource } from "./image-archive-urls";
@@ -170,6 +171,7 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
}
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));
for (let attempt = 0; attempt <= retries; attempt += 1) {
diff --git a/lib/storage/image-byte-cache.ts b/lib/storage/image-byte-cache.ts
index 83b2e87d8..50db11b92 100644
--- a/lib/storage/image-byte-cache.ts
+++ b/lib/storage/image-byte-cache.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
import { join } from "node:path";
@@ -81,6 +82,7 @@ function isMediaResponse(data: ImageBytes) {
return data.buffer.length > 0;
}
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 () => {
if (url.startsWith("data:")) {
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
diff --git a/lib/storage/migration-db.ts b/lib/storage/migration-db.ts
index f67599fd3..49e54f3fe 100644
--- a/lib/storage/migration-db.ts
+++ b/lib/storage/migration-db.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { isRetiredStorageUrl } from "./retired-storage";
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
@@ -16,6 +17,8 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
config &&
config.type === "s3" &&
config.isEnabled &&
+ !isRetiredStorageUrl(config.endpoint) &&
+ !isRetiredStorageUrl(config.publicUrl) &&
String(config.endpoint || "").trim() &&
String(config.bucket || "").trim() &&
String(config.accessKeyId || "").trim() &&
@@ -80,7 +83,7 @@ export async function enqueueStorageMigrationTask({
migrateAfterAt: Date;
cleanupAfterAt: Date;
}) {
- if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
+ if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
await db
.insert(storageMigrationTask)
diff --git a/lib/storage/retired-storage.ts b/lib/storage/retired-storage.ts
new file mode 100644
index 000000000..b5e5a7779
--- /dev/null
+++ b/lib/storage/retired-storage.ts
@@ -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;
+}
diff --git a/lib/storage/service.ts b/lib/storage/service.ts
index d90b4dc5f..7bf669ca6 100644
--- a/lib/storage/service.ts
+++ b/lib/storage/service.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
@@ -149,6 +150,9 @@ export function getContentType(filename: string): string {
}
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 lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
@@ -337,6 +341,8 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
!value ||
!provider ||
!value.isEnabled ||
+ isRetiredStorageUrl(endpoint) ||
+ isRetiredStorageUrl(String(value.publicUrl || "")) ||
!endpoint ||
!bucket ||
!accessKeyId ||
@@ -364,7 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.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 {
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
diff --git a/public/brand/logo-black.png b/public/brand/logo-black.png
new file mode 100644
index 000000000..b7baa95d2
Binary files /dev/null and b/public/brand/logo-black.png differ
diff --git a/public/brand/logo-white.svg b/public/brand/logo-white.svg
new file mode 100644
index 000000000..394ac8438
--- /dev/null
+++ b/public/brand/logo-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/storage/retire-1080p-storage.cjs b/scripts/storage/retire-1080p-storage.cjs
new file mode 100644
index 000000000..e50dd7640
--- /dev/null
+++ b/scripts/storage/retire-1080p-storage.cjs
@@ -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());
diff --git a/tests/retired-storage.config.ts b/tests/retired-storage.config.ts
new file mode 100644
index 000000000..fa5bfd217
--- /dev/null
+++ b/tests/retired-storage.config.ts
@@ -0,0 +1,2 @@
+import { defineConfig } from '@playwright/test';
+export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });
diff --git a/tests/routes/retired-storage.test.ts b/tests/routes/retired-storage.test.ts
new file mode 100644
index 000000000..2aab014d2
--- /dev/null
+++ b/tests/routes/retired-storage.test.ts
@@ -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);
+});