256 lines
7.7 KiB
TypeScript
256 lines
7.7 KiB
TypeScript
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: <T = void>(
|
|
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
|
|
) => express.RequestHandler;
|
|
upload: any;
|
|
uploadDir: string;
|
|
backendRoot: string;
|
|
getBackendVersion: () => string;
|
|
parseJsonField: <T>(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<void>;
|
|
invalidateDrawingsCache: () => void;
|
|
removeFileIfExists: (filePath?: string) => Promise<void>;
|
|
verifyDatabaseIntegrityAsync: (filePath: string) => Promise<boolean>;
|
|
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>): 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<string>();
|
|
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 = <T>(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<string> => {
|
|
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<string | null> => {
|
|
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 };
|