forked from jappel/leistungsbilanz-ts
- make quick/check/mutation from cj/security-baseline (Makefile unchanged, settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint security rules, Forgejo workflow - tests/security: generic baseline tests plus app config and role tests against createApp() with an in-memory database - extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests - fix gaps found by the tests: reject unknown fields on project create, answer malformed JSON with 400 instead of 500, set nosniff on the API - npm audit fix (next 16.4.1 and others, no high/critical left) - clear ESLint errors, safeFileName without control-character regex - per-project ownership stays an open, documented gap (idorGap -> TODO) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
7.6 KiB
TypeScript
88 lines
7.6 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
assertExternalInitialImportProjectCommand,
|
|
createExternalInitialImportProjectCommand,
|
|
isEmptyExternalModelState,
|
|
} from "../../domain/models/external-initial-import-project-command.model.js";
|
|
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
|
|
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
|
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
|
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
|
|
import type { AppDatabase } from "../database-context.js";
|
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
|
import { projectDevices } from "../schema/project-devices.js";
|
|
import { rooms } from "../schema/rooms.js";
|
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
|
|
|
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
|
constructor(private readonly database: AppDatabase) {}
|
|
|
|
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
|
assertExternalInitialImportProjectCommand(input.command);
|
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
|
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
|
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
|
|
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
|
const payload = input.command.payload;
|
|
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
|
|
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
|
}
|
|
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
|
|
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
|
|
replaceState(tx, input.projectId, payload.targetState);
|
|
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
|
|
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
|
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
|
|
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
|
|
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
|
|
const batch = state.importBatches[0];
|
|
if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig.");
|
|
if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig.");
|
|
const bytes = decodeCanonicalBase64(batch.originalContentBase64!);
|
|
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig.");
|
|
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
|
|
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
|
|
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
|
|
const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id));
|
|
for (const mapping of state.roomMappings) {
|
|
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt.");
|
|
if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt.");
|
|
}
|
|
for (const object of state.objects) {
|
|
if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart.");
|
|
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler.");
|
|
if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät.");
|
|
}
|
|
}
|
|
|
|
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
|
|
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
|
|
if (target) database.insert(externalModelConfigurations).values(target).run();
|
|
}
|
|
|
|
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) {
|
|
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
|
|
if (!target.source) return;
|
|
database.insert(externalModelSources).values(target.source).run();
|
|
insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") })));
|
|
insertManyInBatches(database, externalRoomMappings, target.roomMappings);
|
|
insertManyInBatches(database, externalModelObjects, target.objects);
|
|
}
|
|
|
|
function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
|
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
|
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
|
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|