import express from "express"; import path from "path"; import { promises as fsPromises } from "fs"; import JSZip from "jszip"; import { z } from "zod"; import { Prisma, PrismaClient } from "../../generated/client"; import { sanitizeDrawingData } from "../../security"; export class ImportValidationError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = "ImportValidationError"; this.status = status; } } export const excalidashManifestSchemaV1 = z.object({ format: z.literal("excalidash"), formatVersion: z.literal(1), exportedAt: z.string().min(1), excalidashBackendVersion: z.string().optional(), userId: z.string().optional(), unorganizedFolder: z.string().min(1), collections: z.array( z.object({ id: z.string().min(1), name: z.string(), folder: z.string().min(1), createdAt: z.string().optional(), updatedAt: z.string().optional(), }) ), drawings: z.array( z.object({ id: z.string().min(1), name: z.string(), filePath: z.string().min(1), collectionId: z.string().nullable(), version: z.number().int().optional(), createdAt: z.string().optional(), updatedAt: z.string().optional(), }) ), }); export type RegisterImportExportDeps = { app: express.Express; prisma: PrismaClient; requireAuth: express.RequestHandler; asyncHandler: ( fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise ) => express.RequestHandler; upload: any; uploadDir: string; backendRoot: string; getBackendVersion: () => string; parseJsonField: (rawValue: string | null | undefined, fallback: T) => T; sanitizeText: (input: unknown, maxLength?: number) => string; validateImportedDrawing: (data: unknown) => boolean; ensureTrashCollection: ( db: Prisma.TransactionClient | PrismaClient, userId: string ) => Promise; invalidateDrawingsCache: () => void; removeFileIfExists: (filePath?: string) => Promise; verifyDatabaseIntegrityAsync: (filePath: string) => Promise; MAX_IMPORT_ARCHIVE_ENTRIES: number; MAX_IMPORT_COLLECTIONS: number; MAX_IMPORT_DRAWINGS: number; MAX_IMPORT_MANIFEST_BYTES: number; MAX_IMPORT_DRAWING_BYTES: number; MAX_IMPORT_TOTAL_EXTRACTED_BYTES: number; }; const getZipEntries = (zip: JSZip) => Object.values(zip.files).filter((entry) => !entry.dir); export const normalizeArchivePath = (filePath: string): string => path.posix.normalize(filePath.replace(/\\/g, "/")); export const assertSafeArchivePath = (filePath: string) => { const normalized = normalizeArchivePath(filePath); if ( normalized.length === 0 || path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") || normalized.includes("\0") ) { throw new ImportValidationError(`Unsafe archive path: ${filePath}`); } }; export const assertSafeZipArchive = (zip: JSZip, maxEntries: number) => { const entries = getZipEntries(zip); if (entries.length > maxEntries) { throw new ImportValidationError("Archive contains too many files"); } for (const entry of entries) { assertSafeArchivePath(entry.name); } }; export const getSafeZipEntry = (zip: JSZip, filePath: string) => { const normalizedPath = normalizeArchivePath(filePath); assertSafeArchivePath(normalizedPath); return zip.file(normalizedPath); }; export const sanitizePathSegment = (input: string, fallback: string): string => { const value = typeof input === "string" ? input.trim() : ""; const cleaned = value .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") .replace(/\s+/g, " ") .slice(0, 120) .trim(); return cleaned.length > 0 ? cleaned : fallback; }; export const makeUniqueName = (base: string, used: Set): string => { let candidate = base; let n = 2; while (used.has(candidate)) { candidate = `${base}__${n}`; n += 1; } used.add(candidate); return candidate; }; export const findFirstDuplicate = (values: string[]): string | null => { const seen = new Set(); for (const value of values) { if (seen.has(value)) return value; seen.add(value); } return null; }; export const normalizeNonEmptyId = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; export const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; export const isTrashCollectionId = ( collectionId: string | null | undefined, userId: string ): boolean => Boolean(collectionId) && (collectionId === "trash" || collectionId === getUserTrashCollectionId(userId)); export const toPublicTrashCollectionId = ( collectionId: string | null | undefined, userId: string ): string | null => isTrashCollectionId(collectionId, userId) ? "trash" : collectionId ?? null; export const findSqliteTable = (tables: string[], candidates: string[]): string | null => { const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); for (const candidate of candidates) { const found = byLower.get(candidate.toLowerCase()); if (found) return found; } return null; }; export const parseOptionalJson = (raw: unknown, fallback: T): T => { if (typeof raw === "string") { try { return JSON.parse(raw) as T; } catch { return fallback; } } if (typeof raw === "object" && raw !== null) { return raw as T; } return fallback; }; const isPathInsideDirectory = (candidatePath: string, rootDir: string): boolean => { const relativePath = path.relative(rootDir, candidatePath); return ( relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) ); }; const isSafeMulterTempFilename = (value: string): boolean => /^[a-f0-9]{32}$/.test(value); export const resolveSafeUploadedFilePath = async ( fileMeta: { filename?: unknown }, uploadRoot: string ): Promise => { const absoluteUploadRoot = path.resolve(uploadRoot); let canonicalUploadRoot = absoluteUploadRoot; try { canonicalUploadRoot = await fsPromises.realpath(absoluteUploadRoot); } catch { throw new ImportValidationError("Invalid upload path"); } const filename = typeof fileMeta.filename === "string" ? fileMeta.filename : ""; if (!isSafeMulterTempFilename(filename)) { throw new ImportValidationError("Invalid upload path"); } const joinedPath = path.resolve(canonicalUploadRoot, filename); if (!isPathInsideDirectory(joinedPath, canonicalUploadRoot)) { throw new ImportValidationError("Invalid upload path"); } return joinedPath; }; export const openReadonlySqliteDb = (filePath: string): any => { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { DatabaseSync } = require("node:sqlite") as any; return new DatabaseSync(filePath, { readOnly: true, enableForeignKeyConstraints: false, }); } catch { // eslint-disable-next-line @typescript-eslint/no-var-requires const Database = require("better-sqlite3") as any; return new Database(filePath, { readonly: true, fileMustExist: true }); } }; export const getCurrentLatestPrismaMigrationName = async ( backendRoot: string ): Promise => { try { const migrationsDir = path.resolve(backendRoot, "prisma/migrations"); const entries = await fsPromises.readdir(migrationsDir, { withFileTypes: true }); const dirs = entries .filter((e) => e.isDirectory()) .map((e) => e.name) .filter((name) => !name.startsWith(".")); if (dirs.length === 0) return null; dirs.sort(); return dirs[dirs.length - 1] || null; } catch { return null; } }; export { sanitizeDrawingData };