taskmanager/scripts/migrate-uploads.ts
Weapie 007802d56e
Some checks failed
check / verify (push) Failing after 1m22s
Document production rollout and verify complete legacy data migration
2026-10-06 16:03:50 +02:00

45 lines
2.4 KiB
TypeScript

import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import path from "node:path";
import { readFile, realpath } from "node:fs/promises";
import { createHash } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import { saveFile, removeFile, storagePath, MAX_UPLOAD } from "../src/lib/storage";
const db = new PrismaClient();
const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
async function main() {
const root = path.resolve(process.env.LEGACY_UPLOAD_DIR || "public/uploads");
const files = await db.file.findMany();
async function source(file: typeof files[number]) {
if (!file.path.startsWith("/uploads/")) return storagePath(file.path);
const base = await realpath(root);
const resolved = await realpath(path.resolve(base, file.path.slice("/uploads/".length)));
if (!resolved.startsWith(base + path.sep)) throw new Error(`Anhang ${file.id}: Pfad außerhalb des Upload-Verzeichnisses`);
return resolved;
}
// Validate every referenced file before converting any paths. Never silently skip data.
for (const file of files) {
const bytes = await readFile(await source(file));
if (bytes.length !== file.size) throw new Error(`Anhang ${file.id}: Dateigröße stimmt nicht mit der Datenbank überein`);
const type = await fileTypeFromBuffer(bytes);
if (!bytes.length || bytes.length > MAX_UPLOAD || !type || !["pdf", "jpg", "png", "docx", "xlsx"].includes(type.ext))
throw new Error(`Anhang ${file.id}: Inhalt oder Größe nicht zulässig; Original bleibt erhalten`);
}
let migrated = 0;
for (const file of files.filter(f => f.path.startsWith("/uploads/"))) {
const bytes = await readFile(await source(file));
const saved = await saveFile(bytes);
try {
if (sha(await readFile(storagePath(saved.key))) !== sha(bytes)) throw new Error(`Anhang ${file.id}: Prüfsummenabweichung`);
await db.file.update({ where: { id: file.id }, data: { path: saved.key, mimeType: saved.mime } });
} catch (e) {
await removeFile(saved.key);
throw e;
}
// Keep originals for recovery. The volume is private and never served statically.
migrated++;
}
console.log(`${files.length} Anhänge geprüft, ${migrated} migriert. Originaldateien bleiben erhalten; öffentliche /uploads/-Auslieferung sperren.`);
}
main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => db.$disconnect());