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[24] !== 16) || (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.-]+\/)*[\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) { let source: URL; try { source = new URL(sourceUrl); } catch { return null; } if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null; const target = await resolveS3UploadTarget(); const config = target?.config; if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) { return null; } // Files already in our bucket and inline previews need no remote copy. const localOrigins = [config.publicUrl, config.endpoint, process.env.CANVAS_QINIU_ORIGIN_URL, process.env.NEXT_PUBLIC_APP_URL, process.env.AUTH_URL, process.env.NEXTAUTH_URL]; if (localOrigins.some(value => value && new URL(value).origin === source.origin) || source.hostname === "localhost" || source.hostname === "[::1]" || /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null; // The archive task owns this object key, including retries after a timeout. const pathname = `uploads/ai-generated-archive-${archiveId}`; return fetchImageIntoQiniu(sourceUrl, pathname, target!); } /** Prepare uploaded WebP images inside Kodo, retaining their embedded colour profile. */ export async function prepareQiniuWebpDownload(url: string, taskId: string) { const target = await resolveS3UploadTarget(); const config = target?.config; if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) return; const base = buildS3PublicUrl(config, ""); if (!url.startsWith(base) || !/^uploads\/(?:[\w.-]+\/)*[\w.-]+\.webp$/i.test(url.slice(base.length))) return; // No resizing or lossy re-encoding; apply orientation before removing EXIF. const source = `${url}?imageMogr2/auto-orient/format/png/strip/meta-keep-list/${encodeQiniu("icc")}`; const result = await fetchImageIntoQiniu(source, `uploads/canvas-download-${taskId}.png`, target!); return result.downloadAsset; } async function fetchImageIntoQiniu( sourceUrl: string, pathname: string, target: NonNullable>>, ) { const config = target.config; 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, }; }