From 1daf08a9649d646482c016f7ea10a22d52b1e283 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 3 Nov 2026 13:36:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9C=E7=94=A8=E6=97=A7=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=B9=B6=E6=9B=B4=E6=96=B0=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=20Logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。 提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。 验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。 --- app/(chat)/gallery/page.tsx | 10 ++- app/api/canvas/3d/model-proxy/route.ts | 6 +- app/api/canvas/download-image/route.ts | 7 +- app/api/featured/route.ts | 10 ++- app/api/image-proxy/route.ts | 4 + app/api/site-settings/route.ts | 13 +-- components/canvas/nodes/ImageNode.tsx | 7 +- components/home/lovart/home-image.tsx | 3 +- .../home/lovart/lovart-featured-gallery.tsx | 13 +-- components/image-preview.tsx | 5 ++ docs/retired-storage.md | 33 ++++++++ lib/canvas/photoshop-safe-download.ts | 4 + lib/db/queries.ts | 5 +- lib/site-branding.ts | 13 +++ lib/storage/image-archive-db.ts | 3 +- lib/storage/image-archive.ts | 2 + lib/storage/image-byte-cache.ts | 2 + lib/storage/migration-db.ts | 5 +- lib/storage/retired-storage.ts | 16 ++++ lib/storage/service.ts | 8 +- public/brand/logo-black.png | Bin 0 -> 8345 bytes public/brand/logo-white.svg | 1 + scripts/storage/retire-1080p-storage.cjs | 29 +++++++ tests/retired-storage.config.ts | 2 + tests/routes/retired-storage.test.ts | 76 ++++++++++++++++++ 25 files changed, 251 insertions(+), 26 deletions(-) create mode 100644 docs/retired-storage.md create mode 100644 lib/site-branding.ts create mode 100644 lib/storage/retired-storage.ts create mode 100644 public/brand/logo-black.png create mode 100644 public/brand/logo-white.svg create mode 100644 scripts/storage/retire-1080p-storage.cjs create mode 100644 tests/retired-storage.config.ts create mode 100644 tests/routes/retired-storage.test.ts diff --git a/app/(chat)/gallery/page.tsx b/app/(chat)/gallery/page.tsx index fbba95141..2816cd436 100644 --- a/app/(chat)/gallery/page.tsx +++ b/app/(chat)/gallery/page.tsx @@ -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() { <>
- {images.map((img) => ( + {images.map((img) => isRetiredStorageUrl(img.url) ? ( +
+

{RETIRED_STORAGE_MESSAGE}

+

{img.alt}

+
+ ) : (
{img.alt} ({}))) as Record; 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 }); diff --git a/app/api/featured/route.ts b/app/api/featured/route.ts index 6117ec908..493f430cf 100644 --- a/app/api/featured/route.ts +++ b/app/api/featured/route.ts @@ -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, diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index ff8d75bd7..b09508b9d 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -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 }); } diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index adfa919cb..1a58be1bd 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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 | 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 折优惠", diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index b6bac0d81..c2b685fe9 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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 ? (
- {!shouldRequestDisplayImage ? ( + {isRetiredStorageUrl(data.src) ? ( +
+ {RETIRED_STORAGE_MESSAGE} +
+ ) : !shouldRequestDisplayImage ? ( ) : isCompareMode && hasCompareSource ? (
; @@ -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 0000000000000000000000000000000000000000..b7baa95d257b820fb55f68e18307915e19d2b5b8 GIT binary patch literal 8345 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 \ 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); +});