ai-chatbot/lib/storage/qiniu-image-archive.ts

122 lines
4.9 KiB
TypeScript

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<ReturnType<typeof resolveS3UploadTarget>>) {
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,
};
}