停用旧图片存储并更新主题 Logo

s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。

提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。

验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
This commit is contained in:
WuYongJie 2026-11-03 13:36:44 +08:00
parent 348cf4cf5d
commit 1daf08a964
25 changed files with 251 additions and 26 deletions

View File

@ -1,4 +1,5 @@
"use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
@ -316,7 +317,12 @@ export default function GalleryPage() {
<>
<div className="flex-1 overflow-y-auto">
<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
key={img.id}
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">
<Image
src={img.thumbnailUrl || img.url}
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
alt={img.alt}
width={200}
height={270}

View File

@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"tripo3d.ai",
"tripo3d.com",
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com",
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai",
];
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
const { searchParams } = new URL(request.url);
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) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
}

View File

@ -1,4 +1,5 @@
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 { after, NextRequest, NextResponse } from "next/server";
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 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];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });

View File

@ -1,7 +1,8 @@
import { NextRequest } from "next/server";
import { db } from "@/lib/db";
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";
const FEATURED_TIMEOUT_MS = 1800;
@ -63,7 +64,10 @@ export async function GET(request: NextRequest) {
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.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))
.limit(pageSize)
.offset(offset);
@ -74,7 +78,7 @@ export async function GET(request: NextRequest) {
return {
images: featuredImages.map((img) => ({
id: img.id,
thumbnailUrl: img.thumbnailUrl,
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
originalUrl: img.originalUrl,
imageSize: img.imageSize,
prompt: img.prompt,

View File

@ -1,4 +1,5 @@
import crypto from "node:crypto";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
@ -266,6 +267,9 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const rawUrl = (searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
}

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
@ -663,12 +664,12 @@ async function readScopedSiteSettings(keys: string[]) {
}
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return {
return resolveSiteBranding({
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null,
siteLogoDark: settings?.siteLogoDark || null,
favicon: settings?.favicon || null,
};
siteLogo: String(settings?.siteLogo || ""),
siteLogoDark: String(settings?.siteLogoDark || ""),
favicon: String(settings?.favicon || ""),
});
}
async function getScopedPublicSiteSettings(
@ -1541,7 +1542,7 @@ export async function GET(request: NextRequest) {
};
const response = NextResponse.json({
...baseSettings,
...resolveSiteBranding(baseSettings),
lovartPromoText:
baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

View File

@ -1,4 +1,5 @@
"use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import {
Box,
@ -8439,7 +8440,11 @@ export function ImageNode({
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<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 />
) : isCompareMode && hasCompareSource ? (
<div

View File

@ -1,6 +1,7 @@
"use client";
import { useState, type ReactNode } from "react";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
type HomeImageProps = {
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. */
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 <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
}

View File

@ -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 (
<section
ref={featuredSectionRef}
@ -102,7 +102,7 @@ export function LovartFeaturedGallery({
{unavailableCount > 0 && (
<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
type="button"
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
@ -127,18 +127,21 @@ export function LovartFeaturedGallery({
<button
key={`${imageIdentity(item)}:${retryVersion}`}
type="button"
disabled={unavailable.has(imageIdentity(item))}
onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
}
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]}
alt={item.prompt || "灵感图"}
loading="lazy"
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]"
/>
/>}
<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">
<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">

View File

@ -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 <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
}
return (
<>
{/* 缩略图(显示压缩后的缩略图)*/}

33
docs/retired-storage.md Normal file
View 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 的差额。本次未删除或改动七牛对象。

View File

@ -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<CanvasDownloadAssetResult> {
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;

View File

@ -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);
}
}

13
lib/site-branding.ts Normal file
View 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,
};
}

View File

@ -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<string, unknown> | 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;

View File

@ -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) {

View File

@ -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);

View File

@ -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)

View 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;
}

View File

@ -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",

BIN
public/brand/logo-black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View 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());

View File

@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: './routes', testMatch: /retired-storage\.test\.ts/, workers: 1, reporter: 'list' });

View 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);
});