refactor index.ts
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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);
|
||||
}
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user