ai-chatbot/scripts/storage/migrate-historical-generated-images.ts

472 lines
14 KiB
TypeScript

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { createHash } from "node:crypto";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
} from "node:fs";
import { dirname } from "node:path";
import postgres from "postgres";
import { replaceImageUrlInDatabase } from "@/lib/storage/image-url-replacement";
type ManifestStatus =
| "uploaded"
| "upload_failed"
| "completed"
| "replace_failed";
type ManifestEvent = {
sourceUrl: string;
status: ManifestStatus;
targetUrl?: string;
pathname?: string;
bytes?: number;
sha256?: string;
changed?: number;
error?: string;
recordedAt: string;
};
type StorageConfigRow = {
id: string;
name: string;
type: string;
endpoint: string;
region: string | null;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicUrl: string;
isEnabled: boolean;
};
const MAX_IMAGE_BYTES = 100 * 1024 * 1024;
function readArg(name: string, fallback = "") {
const prefix = `--${name}=`;
return (
process.argv.slice(2).find((item) => item.startsWith(prefix))?.slice(prefix.length) ||
fallback
).trim();
}
function hasFlag(name: string) {
return process.argv.slice(2).includes(`--${name}`);
}
function parseDate(value: string, name: string) {
const parsed = new Date(`${value}T00:00:00.000Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid --${name} date: ${value}`);
}
return parsed;
}
function safeError(error: unknown) {
return String(error instanceof Error ? error.message : error || "Unknown error").slice(
0,
2000
);
}
function loadManifest(path: string) {
const latest = new Map<string, ManifestEvent>();
if (!existsSync(path)) return latest;
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as ManifestEvent;
if (event.sourceUrl && event.status) latest.set(event.sourceUrl, event);
} catch {
throw new Error(`Invalid JSONL manifest line in ${path}`);
}
}
return latest;
}
function appendManifest(path: string, event: ManifestEvent) {
mkdirSync(dirname(path), { recursive: true });
appendFileSync(path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
function getExtension(contentType: string, sourceUrl: string) {
const normalizedType = contentType.split(";")[0]?.trim().toLowerCase();
const byType: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/avif": "avif",
"image/svg+xml": "svg",
};
if (byType[normalizedType]) return byType[normalizedType];
try {
const extension = new URL(sourceUrl).pathname
.split(".")
.pop()
?.toLowerCase()
.replace(/[^a-z0-9]/g, "");
if (extension && ["jpg", "jpeg", "png", "webp", "gif", "avif", "svg"].includes(extension)) {
return extension === "jpeg" ? "jpg" : extension;
}
} catch {
// Use the safe fallback below.
}
return "img";
}
async function downloadImage(sourceUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetch(sourceUrl, {
headers: {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
"User-Agent": "AI-Chatbot-Historical-Qiniu-Migrator/1.0",
},
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`download HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get("content-length") || 0);
if (contentLength > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
const contentType = String(response.headers.get("content-type") || "");
if (contentType && !contentType.toLowerCase().startsWith("image/")) {
throw new Error(`unexpected content-type: ${contentType}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) throw new Error("downloaded image is empty");
if (bytes.length > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
return {
bytes,
contentType: contentType.split(";")[0]?.trim() || "application/octet-stream",
};
} catch (error) {
lastError = error;
if (attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
}
}
throw lastError;
}
async function verifyPublicUrl(url: string) {
let lastStatus = 0;
for (let attempt = 1; attempt <= 4; attempt += 1) {
const head = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(20_000),
headers: { "User-Agent": "AI-Chatbot-Historical-Qiniu-Verifier/1.0" },
}).catch(() => null);
lastStatus = head?.status || 0;
if (head?.ok) return;
const ranged = await fetch(url, {
signal: AbortSignal.timeout(20_000),
headers: {
Range: "bytes=0-0",
"User-Agent": "AI-Chatbot-Historical-Qiniu-Verifier/1.0",
},
}).catch(() => null);
lastStatus = ranged?.status || lastStatus;
if (ranged?.ok) return;
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
throw new Error(`uploaded object is not publicly readable (HTTP ${lastStatus || "unknown"})`);
}
function buildTargetUrl(publicUrl: string, key: string) {
return `${publicUrl.replace(/\/+$/, "")}/${key.replace(/^\/+/, "")}`;
}
async function runPool<T>(
values: T[],
concurrency: number,
worker: (value: T, index: number) => Promise<void>
) {
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
await worker(values[index], index);
}
})
);
}
async function main() {
const databaseUrl = process.env.POSTGRES_URL;
if (!databaseUrl) throw new Error("POSTGRES_URL is required");
const fromText = readArg("from", "2026-06-01");
const toText = readArg("to", "2026-09-01");
const from = parseDate(fromText, "from");
const to = parseDate(toText, "to");
if (to <= from) throw new Error("--to must be later than --from");
const targetStorageId = readArg("target-storage-id");
const manifestPath = readArg("manifest");
const concurrency = Math.max(
1,
Math.min(48, Number(readArg("concurrency", "4")) || 4)
);
const limit = Math.max(0, Number(readArg("limit", "0")) || 0);
const execute = hasFlag("execute");
if (execute && (!targetStorageId || !manifestPath)) {
throw new Error("--execute requires --target-storage-id and --manifest");
}
const sqlClient = postgres(databaseUrl, {
connect_timeout: 15,
idle_timeout: 20,
max: Math.max(2, concurrency),
prepare: false,
});
try {
const candidateRows = await sqlClient<{ url: string }[]>`
WITH candidate_urls AS (
SELECT "originalUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
UNION
SELECT "thumbnailUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
)
SELECT url
FROM candidate_urls
WHERE url ~ '^https?://'
ORDER BY url
`;
const hostCounts = new Map<string, number>();
for (const { url } of candidateRows) {
const host = new URL(url).hostname.toLowerCase();
hostCounts.set(host, (hostCounts.get(host) || 0) + 1);
}
console.log(
JSON.stringify({
mode: execute ? "execute" : "plan",
from: fromText,
to: toText,
distinctUrls: candidateRows.length,
hosts: Object.fromEntries(
[...hostCounts.entries()].sort((left, right) => right[1] - left[1])
),
})
);
if (!execute) return;
const storageRows = await sqlClient<StorageConfigRow[]>`
SELECT
id, name, type, endpoint, region, bucket,
"accessKeyId", "secretAccessKey", "publicUrl", "isEnabled"
FROM "StorageConfig"
WHERE id = ${targetStorageId}
LIMIT 1
`;
const storage = storageRows[0];
if (!storage || !storage.isEnabled || storage.type !== "qiniu-kodo") {
throw new Error("Enabled Qiniu target storage config not found");
}
if (!storage.endpoint || !storage.bucket || !storage.accessKeyId || !storage.secretAccessKey || !storage.publicUrl) {
throw new Error("Qiniu target storage config is incomplete");
}
const targetHost = new URL(storage.publicUrl).hostname.toLowerCase();
const manifest = loadManifest(manifestPath);
const pendingCandidates = candidateRows
.map((row) => row.url)
.filter((url) => new URL(url).hostname.toLowerCase() !== targetHost)
.filter((url) => {
const status = manifest.get(url)?.status;
return status !== "uploaded" && status !== "completed";
});
const uploadCandidates = limit > 0 ? pendingCandidates.slice(0, limit) : pendingCandidates;
const s3 = new S3Client({
endpoint: storage.endpoint,
region: storage.region || "cn-east-1",
credentials: {
accessKeyId: storage.accessKeyId,
secretAccessKey: storage.secretAccessKey,
},
forcePathStyle: true,
});
let uploaded = 0;
let uploadFailed = 0;
await runPool(uploadCandidates, concurrency, async (sourceUrl) => {
try {
const downloaded = await downloadImage(sourceUrl);
const contentSha = createHash("sha256").update(downloaded.bytes).digest("hex");
const sourceSha = createHash("sha256").update(sourceUrl).digest("hex");
const extension = getExtension(downloaded.contentType, sourceUrl);
const key = `historical-images/${fromText}_${toText}/${sourceSha.slice(0, 24)}-${contentSha.slice(0, 12)}.${extension}`;
await s3.send(
new PutObjectCommand({
Bucket: storage.bucket,
Key: key,
Body: downloaded.bytes,
ContentType: downloaded.contentType,
CacheControl: "public, max-age=31536000, immutable",
})
);
const targetUrl = buildTargetUrl(storage.publicUrl, key);
await verifyPublicUrl(targetUrl);
const event: ManifestEvent = {
sourceUrl,
targetUrl,
pathname: key,
status: "uploaded",
bytes: downloaded.bytes.length,
sha256: contentSha,
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, event);
manifest.set(sourceUrl, event);
uploaded += 1;
} catch (error) {
const event: ManifestEvent = {
sourceUrl,
status: "upload_failed",
error: safeError(error),
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, event);
manifest.set(sourceUrl, event);
uploadFailed += 1;
}
const processed = uploaded + uploadFailed;
if (processed % 25 === 0 || processed === uploadCandidates.length) {
console.log(
JSON.stringify({ phase: "upload", processed, total: uploadCandidates.length, uploaded, failed: uploadFailed })
);
}
});
const currentCandidateSet = new Set(candidateRows.map((row) => row.url));
const replaceCandidates = [...manifest.values()].filter(
(event) =>
Boolean(event.targetUrl) &&
(event.status === "uploaded" ||
event.status === "replace_failed" ||
(event.status === "completed" && currentCandidateSet.has(event.sourceUrl)))
);
let replaced = 0;
let replaceFailed = 0;
for (const event of replaceCandidates) {
try {
let changed = 0;
let drained = false;
for (let pass = 0; pass < 20; pass += 1) {
const result = await replaceImageUrlInDatabase(
event.sourceUrl,
event.targetUrl as string
);
changed += result.changed;
if (result.changed === 0) {
drained = true;
break;
}
}
if (!drained) throw new Error("replacement did not drain within 20 passes");
const completed: ManifestEvent = {
...event,
status: "completed",
changed,
error: undefined,
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, completed);
manifest.set(event.sourceUrl, completed);
replaced += 1;
} catch (error) {
const failed: ManifestEvent = {
...event,
status: "replace_failed",
error: safeError(error),
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, failed);
manifest.set(event.sourceUrl, failed);
replaceFailed += 1;
}
const processed = replaced + replaceFailed;
if (processed % 25 === 0 || processed === replaceCandidates.length) {
console.log(
JSON.stringify({ phase: "replace", processed, total: replaceCandidates.length, replaced, failed: replaceFailed })
);
}
}
const remainingRows = await sqlClient<{ count: number }[]>`
WITH candidate_urls AS (
SELECT "originalUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
UNION
SELECT "thumbnailUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
)
SELECT count(*)::int AS count
FROM candidate_urls
WHERE url ~ '^https?://' AND url NOT LIKE ${`${storage.publicUrl.replace(/\/+$/, "")}/%`}
`;
const finalStates = [...manifest.values()];
console.log(
JSON.stringify({
phase: "complete",
uploaded,
uploadFailed,
replaced,
replaceFailed,
remainingNonQiniuUrls: remainingRows[0]?.count || 0,
manifest: {
completed: finalStates.filter((event) => event.status === "completed").length,
uploadFailed: finalStates.filter((event) => event.status === "upload_failed").length,
replaceFailed: finalStates.filter((event) => event.status === "replace_failed").length,
},
})
);
} finally {
await sqlClient.end({ timeout: 5 });
}
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(safeError(error));
process.exit(1);
});