停用旧图片存储并更新主题 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";
|
||||
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}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 折优惠",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
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 { 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;
|
||||
|
||||
@ -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
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 { 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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
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 { 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
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