122 lines
4.9 KiB
TypeScript
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,
|
|
};
|
|
}
|