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