import "server-only"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { createHmac } from "node:crypto"; import { buildS3PublicUrl, createS3Client, resolveS3UploadTarget, } from "@/lib/storage/service"; const QINIU_EAST_CHINA_IO = "iovip-z0.qiniuio.com"; const PNG_HEADER_LIMIT = 64 * 1024; const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex"); export type ReadyImageDownload = { status: "ready"; url: string; contentType: "image/png"; bytes: number; preparedAt: string; }; function encodeQiniu(value: string | Buffer) { return (typeof value === "string" ? Buffer.from(value) : value) .toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); } function isDownloadReadyPng(prefix: Buffer) { if (prefix.length < 41 || !prefix.subarray(0, 8).equals(PNG_SIGNATURE) || prefix.readUInt32BE(8) !== 13 || prefix.toString("ascii", 12, 16) !== "IHDR" || !prefix.readUInt32BE(16) || !prefix.readUInt32BE(20) || prefix[24] !== 8 || (prefix[25] !== 2 && prefix[25] !== 6)) return false; // Animation and EXIF orientation need the existing full-image preparation. // An embedded color profile is fine: the browser gets the exact stored bytes. for (let offset = 33; offset + 8 <= prefix.length;) { const type = prefix.toString("ascii", offset + 4, offset + 8); if (type === "IDAT") return true; if (type === "acTL" || type === "eXIf" || type === "IEND") return false; offset += prefix.readUInt32BE(offset) + 12; } return false; } export async function getReadyQiniuImageDownload(url: string, target?: Awaited>) { const config = (target ?? await resolveS3UploadTarget())?.config; if (config?.provider !== "qiniu-kodo" || !config.publicUrl) return; const base = buildS3PublicUrl(config, ""); if (!url.startsWith(base)) return; const key = url.slice(base.length); if (!/^uploads\/[\w.-]+$/.test(key)) return; const client = createS3Client(config); try { const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key, Range: `bytes=0-${PNG_HEADER_LIMIT - 1}`, }), { abortSignal: AbortSignal.timeout(10_000) }); const range = response.ContentRange?.match(/^bytes 0-(\d+)\/(\d+)$/); const length = response.ContentLength ?? 0; const bytes = Number(range?.[2]); if (response.$metadata.httpStatusCode !== 206 || response.ContentType !== "image/png" || !range || !response.Body || length <= 0 || length > PNG_HEADER_LIMIT || Number(range[1]) + 1 !== length || bytes < length) { (response.Body as NodeJS.ReadableStream & { destroy?: () => void } | undefined)?.destroy?.(); return; } const prefix = Buffer.from(await response.Body.transformToByteArray()); if (prefix.length === length && isDownloadReadyPng(prefix)) { return { status: "ready", url, contentType: "image/png", bytes, preparedAt: new Date().toISOString() } satisfies ReadyImageDownload; } } finally { client.destroy(); } } export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) { if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) { return null; } const target = await resolveS3UploadTarget(); const config = target?.config; if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) { return null; } // The archive task owns this object key, including retries after a timeout. const pathname = `uploads/ai-generated-archive-${archiveId}`; const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`; const contentType = "application/x-www-form-urlencoded"; const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`; const signature = encodeQiniu(createHmac("sha1", config.secretAccessKey).update(signingData).digest()); const response = await fetch(`https://${QINIU_EAST_CHINA_IO}${path}`, { method: "POST", headers: { Authorization: `Qiniu ${config.accessKeyId}:${signature}`, "Content-Type": contentType, }, body: "", redirect: "error", signal: AbortSignal.timeout(40_000), }); if (!response.ok) { await response.body?.cancel(); throw new Error(`Qiniu archive fetch returned HTTP ${response.status}`); } const result = await response.json() as { key?: string; fsize?: number; mimeType?: string }; if (result.key !== pathname || !result.fsize || !result.mimeType?.startsWith("image/")) { throw new Error("Qiniu archive fetch did not return an image"); } const url = buildS3PublicUrl(config, pathname); return { url, thumbnailUrl: url, pathname, contentType: result.mimeType, bytes: result.fsize, downloadAsset: result.mimeType === "image/png" ? await getReadyQiniuImageDownload(url, target).catch(() => undefined) : undefined, }; }