415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
RegisterImportExportDeps,
|
|
ImportValidationError,
|
|
findFirstDuplicate,
|
|
findSqliteTable,
|
|
getCurrentLatestPrismaMigrationName,
|
|
getUserTrashCollectionId,
|
|
normalizeNonEmptyId,
|
|
openReadonlySqliteDb,
|
|
parseOptionalJson,
|
|
resolveSafeUploadedFilePath,
|
|
sanitizeDrawingData,
|
|
} from "./shared";
|
|
|
|
export const registerLegacySqliteImportRoutes = (deps: RegisterImportExportDeps) => {
|
|
const {
|
|
app,
|
|
prisma,
|
|
requireAuth,
|
|
asyncHandler,
|
|
upload,
|
|
uploadDir,
|
|
backendRoot,
|
|
sanitizeText,
|
|
validateImportedDrawing,
|
|
ensureTrashCollection,
|
|
invalidateDrawingsCache,
|
|
removeFileIfExists,
|
|
verifyDatabaseIntegrityAsync,
|
|
MAX_IMPORT_COLLECTIONS,
|
|
MAX_IMPORT_DRAWINGS,
|
|
} = deps;
|
|
|
|
app.post("/import/sqlite/legacy/verify", requireAuth, upload.single("db"), asyncHandler(async (req, res) => {
|
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
|
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
|
|
|
let stagedPath: string;
|
|
try {
|
|
stagedPath = await resolveSafeUploadedFilePath(
|
|
{ filename: req.file.filename },
|
|
uploadDir
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ImportValidationError) {
|
|
return res.status(error.status).json({ error: "Invalid upload", message: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
const isValid = await verifyDatabaseIntegrityAsync(stagedPath);
|
|
if (!isValid) return res.status(400).json({ error: "Invalid database format" });
|
|
|
|
let db: any | null = null;
|
|
try {
|
|
db = openReadonlySqliteDb(stagedPath);
|
|
const tables: string[] = db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
.all()
|
|
.map((row: any) => String(row.name));
|
|
|
|
const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]);
|
|
const collectionTable = findSqliteTable(tables, ["Collection", "collections"]);
|
|
if (!drawingTable) {
|
|
return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" });
|
|
}
|
|
|
|
const drawingsCount = Number(db.prepare(`SELECT COUNT(1) as c FROM "${drawingTable}"`).get()?.c ?? 0);
|
|
const collectionsCount = collectionTable
|
|
? Number(db.prepare(`SELECT COUNT(1) as c FROM "${collectionTable}"`).get()?.c ?? 0)
|
|
: 0;
|
|
if (drawingsCount > MAX_IMPORT_DRAWINGS) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
|
});
|
|
}
|
|
if (collectionsCount > MAX_IMPORT_COLLECTIONS) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`,
|
|
});
|
|
}
|
|
|
|
const duplicateDrawingIdRow = db
|
|
.prepare(
|
|
`SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
|
)
|
|
.get();
|
|
if (duplicateDrawingIdRow?.id) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`,
|
|
});
|
|
}
|
|
if (collectionTable) {
|
|
const duplicateCollectionIdRow = db
|
|
.prepare(
|
|
`SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
|
)
|
|
.get();
|
|
if (duplicateCollectionIdRow?.id) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
let latestMigration: string | null = null;
|
|
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
|
|
if (migrationsTable) {
|
|
try {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT migration_name as name, finished_at as finishedAt FROM "${migrationsTable}" ORDER BY finished_at DESC LIMIT 1`
|
|
)
|
|
.get();
|
|
if (row?.name) latestMigration = String(row.name);
|
|
} catch {
|
|
latestMigration = null;
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
valid: true,
|
|
drawings: drawingsCount,
|
|
collections: collectionsCount,
|
|
latestMigration,
|
|
currentLatestMigration: await getCurrentLatestPrismaMigrationName(backendRoot),
|
|
});
|
|
} catch {
|
|
return res.status(500).json({
|
|
error: "Legacy DB support unavailable",
|
|
message:
|
|
"Failed to open the SQLite database for inspection. If you're on Node < 22, you may need to rebuild native dependencies (e.g. `cd backend && npm rebuild better-sqlite3`).",
|
|
});
|
|
} finally {
|
|
try {
|
|
db?.close?.();
|
|
} catch {}
|
|
}
|
|
} finally {
|
|
await removeFileIfExists(stagedPath);
|
|
}
|
|
}));
|
|
|
|
app.post("/import/sqlite/legacy", requireAuth, upload.single("db"), asyncHandler(async (req, res) => {
|
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
|
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
|
|
|
let stagedPath: string;
|
|
try {
|
|
stagedPath = await resolveSafeUploadedFilePath(
|
|
{ filename: req.file.filename },
|
|
uploadDir
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ImportValidationError) {
|
|
return res.status(error.status).json({ error: "Invalid upload", message: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
const isValid = await verifyDatabaseIntegrityAsync(stagedPath);
|
|
if (!isValid) return res.status(400).json({ error: "Invalid database format" });
|
|
|
|
let legacyDb: any | null = null;
|
|
try {
|
|
legacyDb = openReadonlySqliteDb(stagedPath);
|
|
const tables: string[] = legacyDb
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
.all()
|
|
.map((row: any) => String(row.name));
|
|
|
|
const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]);
|
|
const collectionTable = findSqliteTable(tables, ["Collection", "collections"]);
|
|
if (!drawingTable) {
|
|
return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" });
|
|
}
|
|
|
|
const importedCollections: any[] = collectionTable
|
|
? legacyDb.prepare(`SELECT * FROM "${collectionTable}"`).all()
|
|
: [];
|
|
const importedDrawings: any[] = legacyDb.prepare(`SELECT * FROM "${drawingTable}"`).all();
|
|
|
|
if (importedCollections.length > MAX_IMPORT_COLLECTIONS) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`,
|
|
});
|
|
}
|
|
if (importedDrawings.length > MAX_IMPORT_DRAWINGS) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
|
});
|
|
}
|
|
|
|
const importedCollectionIds = importedCollections
|
|
.map((c) => normalizeNonEmptyId(c?.id))
|
|
.filter((id): id is string => id !== null);
|
|
const duplicateCollectionId = findFirstDuplicate(importedCollectionIds);
|
|
if (duplicateCollectionId) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`,
|
|
});
|
|
}
|
|
|
|
const importedDrawingIds = importedDrawings
|
|
.map((d) => normalizeNonEmptyId(d?.id))
|
|
.filter((id): id is string => id !== null);
|
|
const duplicateDrawingId = findFirstDuplicate(importedDrawingIds);
|
|
if (duplicateDrawingId) {
|
|
return res.status(400).json({
|
|
error: "Invalid legacy DB",
|
|
message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`,
|
|
});
|
|
}
|
|
|
|
type PreparedLegacyDrawing = {
|
|
importedId: string | null;
|
|
name: string;
|
|
sanitized: ReturnType<typeof sanitizeDrawingData>;
|
|
collectionIdRaw: unknown;
|
|
collectionNameRaw: unknown;
|
|
versionRaw: unknown;
|
|
};
|
|
|
|
const preparedDrawings: PreparedLegacyDrawing[] = [];
|
|
for (const d of importedDrawings) {
|
|
const importPayload = {
|
|
name: typeof d.name === "string" ? d.name : "Untitled Drawing",
|
|
elements: parseOptionalJson<unknown[]>(d.elements, []),
|
|
appState: parseOptionalJson<Record<string, unknown>>(d.appState, {}),
|
|
files: parseOptionalJson<Record<string, unknown>>(d.files, {}),
|
|
preview: typeof d.preview === "string" ? d.preview : null,
|
|
collectionId: null as string | null,
|
|
};
|
|
|
|
if (!validateImportedDrawing(importPayload)) {
|
|
return res.status(400).json({
|
|
error: "Invalid imported drawing",
|
|
message: "Legacy database contains invalid drawing data",
|
|
});
|
|
}
|
|
|
|
preparedDrawings.push({
|
|
importedId: typeof d.id === "string" ? d.id : null,
|
|
name: sanitizeText(importPayload.name, 255) || "Untitled Drawing",
|
|
sanitized: sanitizeDrawingData(importPayload),
|
|
collectionIdRaw: d.collectionId,
|
|
collectionNameRaw: d.collectionName,
|
|
versionRaw: d.version,
|
|
});
|
|
}
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const trashCollectionId = getUserTrashCollectionId(req.user!.id);
|
|
const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash");
|
|
if (hasTrash) await ensureTrashCollection(tx, req.user!.id);
|
|
|
|
const collectionIdMap = new Map<string, string>();
|
|
let collectionsCreated = 0;
|
|
let collectionsUpdated = 0;
|
|
let collectionIdConflicts = 0;
|
|
let drawingsCreated = 0;
|
|
let drawingsUpdated = 0;
|
|
let drawingIdConflicts = 0;
|
|
|
|
for (const c of importedCollections) {
|
|
const importedId = typeof c.id === "string" ? c.id : null;
|
|
const name = typeof c.name === "string" ? c.name : "Collection";
|
|
|
|
if (importedId === "trash" || name === "Trash") {
|
|
collectionIdMap.set(importedId || "trash", trashCollectionId);
|
|
continue;
|
|
}
|
|
|
|
if (!importedId) {
|
|
const newId = uuidv4();
|
|
await tx.collection.create({
|
|
data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id },
|
|
});
|
|
collectionIdMap.set(`__name:${name}`, newId);
|
|
collectionsCreated += 1;
|
|
continue;
|
|
}
|
|
|
|
const existing = await tx.collection.findUnique({ where: { id: importedId } });
|
|
if (!existing) {
|
|
await tx.collection.create({
|
|
data: { id: importedId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id },
|
|
});
|
|
collectionIdMap.set(importedId, importedId);
|
|
collectionsCreated += 1;
|
|
continue;
|
|
}
|
|
if (existing.userId === req.user!.id) {
|
|
await tx.collection.update({
|
|
where: { id: importedId },
|
|
data: { name: sanitizeText(name, 100) || "Collection" },
|
|
});
|
|
collectionIdMap.set(importedId, importedId);
|
|
collectionsUpdated += 1;
|
|
continue;
|
|
}
|
|
|
|
const newId = uuidv4();
|
|
await tx.collection.create({
|
|
data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id },
|
|
});
|
|
collectionIdMap.set(importedId, newId);
|
|
collectionsCreated += 1;
|
|
collectionIdConflicts += 1;
|
|
}
|
|
|
|
const resolveImportedCollectionId = (
|
|
rawCollectionId: unknown,
|
|
rawCollectionName: unknown
|
|
): string | null => {
|
|
const id = typeof rawCollectionId === "string" ? rawCollectionId : null;
|
|
const name = typeof rawCollectionName === "string" ? rawCollectionName : null;
|
|
|
|
if (id === "trash" || name === "Trash") return trashCollectionId;
|
|
if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!;
|
|
if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!;
|
|
return null;
|
|
};
|
|
|
|
for (const d of preparedDrawings) {
|
|
const resolvedCollectionId = resolveImportedCollectionId(d.collectionIdRaw, d.collectionNameRaw);
|
|
const existing = d.importedId ? await tx.drawing.findUnique({ where: { id: d.importedId } }) : null;
|
|
|
|
if (!existing) {
|
|
const idToUse = d.importedId || uuidv4();
|
|
await tx.drawing.create({
|
|
data: {
|
|
id: idToUse,
|
|
name: d.name,
|
|
elements: JSON.stringify(d.sanitized.elements),
|
|
appState: JSON.stringify(d.sanitized.appState),
|
|
files: JSON.stringify(d.sanitized.files || {}),
|
|
preview: d.sanitized.preview ?? null,
|
|
version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1,
|
|
userId: req.user!.id,
|
|
collectionId: resolvedCollectionId ?? null,
|
|
},
|
|
});
|
|
drawingsCreated += 1;
|
|
continue;
|
|
}
|
|
|
|
if (existing.userId === req.user!.id) {
|
|
await tx.drawing.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
name: d.name,
|
|
elements: JSON.stringify(d.sanitized.elements),
|
|
appState: JSON.stringify(d.sanitized.appState),
|
|
files: JSON.stringify(d.sanitized.files || {}),
|
|
preview: d.sanitized.preview ?? null,
|
|
version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : existing.version,
|
|
collectionId: resolvedCollectionId ?? null,
|
|
},
|
|
});
|
|
drawingsUpdated += 1;
|
|
continue;
|
|
}
|
|
|
|
const newId = uuidv4();
|
|
await tx.drawing.create({
|
|
data: {
|
|
id: newId,
|
|
name: d.name,
|
|
elements: JSON.stringify(d.sanitized.elements),
|
|
appState: JSON.stringify(d.sanitized.appState),
|
|
files: JSON.stringify(d.sanitized.files || {}),
|
|
preview: d.sanitized.preview ?? null,
|
|
version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1,
|
|
userId: req.user!.id,
|
|
collectionId: resolvedCollectionId ?? null,
|
|
},
|
|
});
|
|
drawingsCreated += 1;
|
|
drawingIdConflicts += 1;
|
|
}
|
|
|
|
return {
|
|
collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts },
|
|
drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts },
|
|
};
|
|
});
|
|
|
|
invalidateDrawingsCache();
|
|
return res.json({ success: true, ...result });
|
|
} catch {
|
|
return res.status(500).json({
|
|
error: "Legacy DB support unavailable",
|
|
message:
|
|
"Failed to open the SQLite database for import. If you're on Node < 22, you may need to rebuild native dependencies (e.g. `cd backend && npm rebuild better-sqlite3`).",
|
|
});
|
|
} finally {
|
|
try {
|
|
legacyDb?.close?.();
|
|
} catch {}
|
|
}
|
|
} finally {
|
|
await removeFileIfExists(stagedPath);
|
|
}
|
|
}));
|
|
};
|