s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。 提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。 验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
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";
|
|
|
|
type DownloadFileItem = {
|
|
url: string;
|
|
name?: string;
|
|
};
|
|
|
|
export type CanvasDownloadAssetResult =
|
|
| {
|
|
ok: true;
|
|
filename: string;
|
|
bytes: Buffer;
|
|
contentType: string;
|
|
}
|
|
| {
|
|
ok: false;
|
|
url: string;
|
|
name?: string;
|
|
error: string;
|
|
};
|
|
|
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_FETCH_RETRY_COUNT = 3;
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function normalizeDownloadError(error: unknown): string {
|
|
if (error instanceof Error) return error.message || "download failed";
|
|
if (typeof error === "string") return error;
|
|
return "download failed";
|
|
}
|
|
|
|
export function sanitizeCanvasDownloadFileName(value: string, fallback: string): string {
|
|
const cleaned = String(value || "")
|
|
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.trim()
|
|
.slice(0, 120);
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
export function buildAttachmentContentDisposition(filename: string): string {
|
|
const safeAsciiName =
|
|
filename
|
|
.replace(/[^\x20-\x7E]+/g, "")
|
|
.replace(/"/g, "")
|
|
.trim() || `canvas-image-${Date.now()}.png`;
|
|
return `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
}
|
|
|
|
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
|
|
const headers: Record<string, string> = {
|
|
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,video/*,*/*;q=0.8",
|
|
"User-Agent":
|
|
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
|
|
Referer: requestOrigin,
|
|
};
|
|
|
|
try {
|
|
const targetOrigin = new URL(fileUrl).origin;
|
|
if (targetOrigin === requestOrigin && cookie) {
|
|
headers.cookie = cookie;
|
|
}
|
|
} catch (_error) {
|
|
// URL has already been normalized before this point; keep this defensive.
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
function resolveHttpUrl(value: string, requestOrigin: string): string {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return "";
|
|
|
|
try {
|
|
const parsed = new URL(raw, requestOrigin);
|
|
if (!/^https?:$/i.test(parsed.protocol)) return "";
|
|
return parsed.toString();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function buildCanvasDownloadSourceCandidates(fileUrl: string, requestOrigin: string) {
|
|
const candidates: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const add = (value: string) => {
|
|
const resolved = resolveHttpUrl(value, requestOrigin);
|
|
if (!resolved || seen.has(resolved)) return;
|
|
seen.add(resolved);
|
|
candidates.push(resolved);
|
|
};
|
|
|
|
const resolvedFileUrl = resolveHttpUrl(fileUrl, requestOrigin);
|
|
const proxySourceUrl = extractImageProxySourceUrl(resolvedFileUrl || fileUrl);
|
|
if (proxySourceUrl) {
|
|
add(inferOriginalImageUrl(proxySourceUrl) || proxySourceUrl);
|
|
add(proxySourceUrl);
|
|
}
|
|
|
|
if (resolvedFileUrl) {
|
|
add(inferOriginalImageUrl(resolvedFileUrl) || resolvedFileUrl);
|
|
add(resolvedFileUrl);
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function getContentTypeMime(contentType: string): string {
|
|
return String(contentType || "")
|
|
.split(";")[0]
|
|
?.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function hasAsciiAt(buffer: Buffer, offset: number, value: string): boolean {
|
|
return buffer.subarray(offset, offset + value.length).toString("latin1") === value;
|
|
}
|
|
|
|
function looksLikeSvg(buffer: Buffer, contentType: string): boolean {
|
|
if (getContentTypeMime(contentType) === "image/svg+xml") return true;
|
|
const text = buffer.subarray(0, Math.min(buffer.length, 1024)).toString("utf8").trimStart();
|
|
return /^(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text);
|
|
}
|
|
|
|
function looksLikeErrorDocument(buffer: Buffer, contentType: string): boolean {
|
|
const mime = getContentTypeMime(contentType);
|
|
if (
|
|
mime.startsWith("text/") ||
|
|
mime === "application/json" ||
|
|
mime === "application/xml" ||
|
|
mime === "application/xhtml+xml"
|
|
) {
|
|
return true;
|
|
}
|
|
const text = buffer.subarray(0, Math.min(buffer.length, 512)).toString("utf8").trimStart();
|
|
return /^(?:<!doctype\s+html|<html[\s>]|<\?xml|<error[\s>]|[{[])/i.test(text);
|
|
}
|
|
|
|
function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "video" | "audio"; mimeType: string; ext: string } | null {
|
|
if (bytes.length < 4) return null;
|
|
|
|
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
return { kind: "image", mimeType: "image/png", ext: "png" };
|
|
}
|
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
return { kind: "image", mimeType: "image/jpeg", ext: "jpg" };
|
|
}
|
|
if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WEBP")) {
|
|
return { kind: "image", mimeType: "image/webp", ext: "webp" };
|
|
}
|
|
if (hasAsciiAt(bytes, 0, "GIF87a") || hasAsciiAt(bytes, 0, "GIF89a")) {
|
|
return { kind: "image", mimeType: "image/gif", ext: "gif" };
|
|
}
|
|
if (hasAsciiAt(bytes, 0, "BM")) {
|
|
return { kind: "image", mimeType: "image/bmp", ext: "bmp" };
|
|
}
|
|
if (
|
|
bytes.subarray(0, 4).equals(Buffer.from([0x49, 0x49, 0x2a, 0x00])) ||
|
|
bytes.subarray(0, 4).equals(Buffer.from([0x4d, 0x4d, 0x00, 0x2a]))
|
|
) {
|
|
return { kind: "image", mimeType: "image/tiff", ext: "tiff" };
|
|
}
|
|
if (looksLikeSvg(bytes, contentType)) {
|
|
return { kind: "image", mimeType: "image/svg+xml", ext: "svg" };
|
|
}
|
|
if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WAVE")) return {kind:"audio",mimeType:"audio/wav",ext:"wav"};
|
|
if (hasAsciiAt(bytes, 0, "ID3") || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 && (bytes[1] & 0x06) !== 0)) return {kind:"audio",mimeType:"audio/mpeg",ext:"mp3"};
|
|
if (hasAsciiAt(bytes, 0, "OggS")) return {kind:"audio",mimeType:"audio/ogg",ext:"ogg"};
|
|
if (hasAsciiAt(bytes, 0, "fLaC")) return {kind:"audio",mimeType:"audio/flac",ext:"flac"};
|
|
if (getContentTypeMime(contentType).startsWith("audio/")) return {kind:"audio",mimeType:getContentTypeMime(contentType),ext:getContentTypeMime(contentType).includes("mp4") ? "m4a" : getContentTypeMime(contentType).split("/")[1]};
|
|
if (hasAsciiAt(bytes, 4, "ftyp")) {
|
|
const brand = bytes.subarray(8, Math.min(bytes.length, 32)).toString("latin1").toLowerCase();
|
|
if (brand.includes("m4a")) return {kind:"audio",mimeType:"audio/mp4",ext:"m4a"};
|
|
if (brand.includes("avif")) return { kind: "image", mimeType: "image/avif", ext: "avif" };
|
|
if (brand.includes("heic") || brand.includes("heif") || brand.includes("mif1")) {
|
|
return { kind: "image", mimeType: "image/heic", ext: "heic" };
|
|
}
|
|
return { kind: "video", mimeType: "video/mp4", ext: "mp4" };
|
|
}
|
|
if (bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) {
|
|
return { kind: "video", mimeType: "video/webm", ext: "webm" };
|
|
}
|
|
|
|
const mime = getContentTypeMime(contentType);
|
|
if (mime.startsWith("image/")) {
|
|
const ext = mime.includes("jpeg") ? "jpg" : mime.split("/")[1] || "png";
|
|
return { kind: "image", mimeType: mime, ext };
|
|
}
|
|
if (mime.startsWith("video/")) {
|
|
const ext = mime.includes("quicktime") ? "mov" : mime.split("/")[1] || "mp4";
|
|
return { kind: "video", mimeType: mime, ext };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer> {
|
|
const sharp = await import("sharp");
|
|
const image = sharp.default(bytes, {
|
|
animated: false,
|
|
failOn: "none",
|
|
limitInputPixels: false,
|
|
});
|
|
const metadata = await image.metadata();
|
|
if (!metadata.width || !metadata.height) {
|
|
throw new Error("无法解析图片尺寸");
|
|
}
|
|
// RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes.
|
|
if (metadata.format === "png" && metadata.space === "srgb" && metadata.depth === "uchar"
|
|
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
|
|
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
|
|
}
|
|
|
|
function buildCanvasAssetFilename(params: {
|
|
item: DownloadFileItem;
|
|
index: number;
|
|
ext: string;
|
|
includeSequencePrefix: boolean;
|
|
}) {
|
|
const baseName = sanitizeCanvasDownloadFileName(
|
|
params.item.name || `canvas-image-${params.index + 1}`,
|
|
`canvas-image-${params.index + 1}`
|
|
);
|
|
if (!params.includeSequencePrefix) {
|
|
return `${baseName}.${params.ext}`;
|
|
}
|
|
const sequencePrefix = String(params.index + 1).padStart(3, "0");
|
|
return `${sequencePrefix}-${baseName}.${params.ext}`;
|
|
}
|
|
|
|
export async function normalizeCanvasDownloadAsset(params: {
|
|
item: DownloadFileItem;
|
|
index: number;
|
|
bytes: Buffer;
|
|
contentType: string;
|
|
includeSequencePrefix: boolean;
|
|
}): Promise<CanvasDownloadAssetResult> {
|
|
if (params.bytes.length === 0) {
|
|
throw new Error("empty response");
|
|
}
|
|
if (looksLikeErrorDocument(params.bytes, params.contentType) && !looksLikeSvg(params.bytes, params.contentType)) {
|
|
throw new Error("源地址返回的不是图片文件");
|
|
}
|
|
|
|
const detected = detectAsset(params.bytes, params.contentType);
|
|
if (!detected) {
|
|
throw new Error("无法识别图片格式");
|
|
}
|
|
|
|
if (detected.kind !== "image") {
|
|
return {
|
|
ok: true,
|
|
filename: buildCanvasAssetFilename({
|
|
item: params.item,
|
|
index: params.index,
|
|
ext: detected.ext,
|
|
includeSequencePrefix: params.includeSequencePrefix,
|
|
}),
|
|
bytes: params.bytes,
|
|
contentType: detected.mimeType,
|
|
};
|
|
}
|
|
|
|
const digest = createHash("sha256").update(params.bytes).digest("hex");
|
|
const { buffer: pngBytes } = await onceImageBytes(`photoshop:${digest}`, async () => ({
|
|
buffer: await convertImageToPhotoshopPng(params.bytes), contentType: "image/png",
|
|
}));
|
|
return {
|
|
ok: true,
|
|
filename: buildCanvasAssetFilename({
|
|
item: params.item,
|
|
index: params.index,
|
|
ext: "png",
|
|
includeSequencePrefix: params.includeSequencePrefix,
|
|
}),
|
|
bytes: pngBytes,
|
|
contentType: "image/png",
|
|
};
|
|
}
|
|
|
|
export async function fetchCanvasDownloadAsset(params: {
|
|
file: DownloadFileItem;
|
|
index: number;
|
|
requestOrigin: string;
|
|
cookie: string;
|
|
includeSequencePrefix?: boolean;
|
|
timeoutMs?: number;
|
|
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;
|
|
const originalCandidates = buildCanvasDownloadSourceCandidates(
|
|
params.file.url,
|
|
params.requestOrigin
|
|
);
|
|
|
|
for (let attempt = 1; attempt <= retryCount; attempt += 1) {
|
|
params.signal?.throwIfAborted();
|
|
const candidateUrls = [...originalCandidates];
|
|
if (process.env.POSTGRES_URL) {
|
|
// ZIPs and binary fallback also reuse the durable prepared object after the
|
|
// local byte cache expires, instead of converting the original JPEG again.
|
|
const { findImageArchiveDownloadTask } = await import("@/lib/storage/image-archive-db");
|
|
const task = await findImageArchiveDownloadTask(candidateUrls).catch(() => null);
|
|
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string } } | null)?.downloadAsset;
|
|
if (asset?.status === "ready" && asset.url && asset.contentType === "image/png") {
|
|
const readyUrl = resolveHttpUrl(asset.url, params.requestOrigin);
|
|
if (readyUrl && !candidateUrls.includes(readyUrl)) candidateUrls.unshift(readyUrl);
|
|
} else if (task?.targetUrl && !candidateUrls.includes(task.targetUrl)) {
|
|
candidateUrls.unshift(task.targetUrl);
|
|
}
|
|
}
|
|
|
|
for (const candidateUrl of candidateUrls) {
|
|
params.signal?.throwIfAborted();
|
|
try {
|
|
const data = await fetchImageBytes(candidateUrl, {
|
|
timeoutMs,
|
|
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
|
// Authenticated local assets must never share a cache across users.
|
|
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
|
? createHash("sha256").update(params.cookie).digest("hex") : "",
|
|
});
|
|
return await normalizeCanvasDownloadAsset({
|
|
item: params.file,
|
|
index: params.index,
|
|
bytes: data.buffer,
|
|
contentType: data.contentType,
|
|
includeSequencePrefix: params.includeSequencePrefix !== false,
|
|
});
|
|
} catch (error) {
|
|
params.signal?.throwIfAborted();
|
|
lastError = normalizeDownloadError(error);
|
|
}
|
|
}
|
|
// Archiving may have completed during the failed fetch. Resolve aliases
|
|
// again before retrying, rather than spending every attempt on the old URL.
|
|
if (attempt < retryCount) await sleep(350 * attempt);
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
url: params.file.url,
|
|
name: params.file.name,
|
|
error: lastError || "图片下载失败",
|
|
};
|
|
}
|