Assessor_2.0/server/reporting/report-archive.ts

164 lines
7.5 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
import type { ReportProjection } from '../../src/reporting/report-projection';
import type { ReportEngineVersions, ReportRun } from '../../src/reporting/report-run';
export const REPORT_ARCHIVE_MANIFEST_VERSION = 1;
export const REPORT_ARCHIVE_FILES = {
projection: 'projection.json',
management: 'management-report.html',
technical: 'technical-report.html',
manifest: 'manifest.json'
} as const;
export interface ReportArchiveManifest {
version: typeof REPORT_ARCHIVE_MANIFEST_VERSION;
/** Internal and confidential: never delivered together with the Management Report. */
handling: 'internal-confidential';
runId: string;
archivedAt: string;
generatedAt: string;
projectId: string;
scope: { assessmentIds?: string[]; scanRunIds?: string[] };
repositoryRevision: number;
versions: ReportEngineVersions;
files: Array<{ name: string; bytes: number; sha256: string }>;
}
export interface ArchivedReportRun {
manifest: ReportArchiveManifest;
projection: ReportProjection;
management: string;
technical: string;
}
const sha256 = (content: Buffer) => createHash('sha256').update(content).digest('hex');
const RUN_ID = /^\d{8}T\d{6}Z-[0-9a-f]{8}$/;
/** Folder name per client: a hash, so a project ID can never form a path. */
export function reportArchiveProjectDirectory(projectId: string): string {
return createHash('sha256').update(projectId, 'utf8').digest('hex').slice(0, 32);
}
/**
* Write-once report archive outside the ASSESSOR database. A run is written into a hidden
* partial folder and only then renamed into place, so a failed run leaves no half archive.
* Archived files are made read-only and are never rewritten.
*/
export class FileSystemReportArchive {
constructor(readonly root: string) {}
async write(run: ReportRun, archivedAt: Date): Promise<ReportArchiveManifest> {
const stamp = archivedAt.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const runId = `${stamp}-${randomUUID().slice(0, 8)}`;
const projectDirectory = join(this.root, reportArchiveProjectDirectory(run.projection.project.id));
await mkdir(projectDirectory, { recursive: true, mode: 0o700 });
const partial = join(projectDirectory, `.partial-${runId}`);
await mkdir(partial, { mode: 0o700 });
try {
const contents: Array<[string, Buffer]> = [
[REPORT_ARCHIVE_FILES.projection, Buffer.from(JSON.stringify(run.projection, null, 2), 'utf8')],
[REPORT_ARCHIVE_FILES.management, Buffer.from(run.files.management, 'utf8')],
[REPORT_ARCHIVE_FILES.technical, Buffer.from(run.files.technical, 'utf8')]
];
const manifest: ReportArchiveManifest = {
version: REPORT_ARCHIVE_MANIFEST_VERSION,
handling: 'internal-confidential',
runId,
archivedAt: archivedAt.toISOString(),
generatedAt: run.projection.generatedAt,
projectId: run.projection.project.id,
scope: { assessmentIds: run.scope.assessmentIds, scanRunIds: run.scope.scanRunIds },
repositoryRevision: run.revision,
versions: run.versions,
files: contents.map(([name, content]) => ({ name, bytes: content.byteLength, sha256: sha256(content) }))
};
contents.push([REPORT_ARCHIVE_FILES.manifest, Buffer.from(JSON.stringify(manifest, null, 2), 'utf8')]);
for (const [name, content] of contents) {
const path = join(partial, name);
await writeFile(path, content, { flag: 'wx', mode: 0o400 });
await chmod(path, 0o444);
}
await rename(partial, join(projectDirectory, runId));
return manifest;
} catch (error) {
await rm(partial, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
/** Reads one archived run and verifies every file against its manifest hash. */
async read(projectId: string, runId: string): Promise<ArchivedReportRun> {
if (!RUN_ID.test(runId)) throw new Error('Ungültige Report-Lauf-ID.');
const directory = join(this.root, reportArchiveProjectDirectory(projectId), runId);
const manifest = JSON.parse(await readFile(join(directory, REPORT_ARCHIVE_FILES.manifest), 'utf8')) as ReportArchiveManifest;
const files = new Map<string, Buffer>();
for (const entry of manifest.files) {
const content = await readFile(join(directory, entry.name));
if (content.byteLength !== entry.bytes || sha256(content) !== entry.sha256) {
throw new Error(`Archivierte Datei ${entry.name} stimmt nicht mit dem Manifest überein.`);
}
files.set(entry.name, content);
}
return {
manifest,
projection: JSON.parse(files.get(REPORT_ARCHIVE_FILES.projection)!.toString('utf8')) as ReportProjection,
management: files.get(REPORT_ARCHIVE_FILES.management)!.toString('utf8'),
technical: files.get(REPORT_ARCHIVE_FILES.technical)!.toString('utf8')
};
}
/** Removes every archived run of a client together with the client. */
async deleteProject(projectId: string): Promise<void> {
await rm(join(this.root, reportArchiveProjectDirectory(projectId)), { recursive: true, force: true, maxRetries: 3 });
}
}
export interface ReportArchiveEnvironment {
ASSESSOR_REPORT_ARCHIVE_DIR?: string;
LOCALAPPDATA?: string;
XDG_DATA_HOME?: string;
OneDrive?: string;
OneDriveCommercial?: string;
OneDriveConsumer?: string;
}
function defaultArchiveRoot(environment: ReportArchiveEnvironment, platform: NodeJS.Platform, home: string): string {
if (platform === 'win32') return join(environment.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'ASSESSOR', 'report-archive');
if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'ASSESSOR', 'report-archive');
return join(environment.XDG_DATA_HOME || join(home, '.local', 'share'), 'assessor', 'report-archive');
}
function inside(path: string, directory: string, platform: NodeJS.Platform): boolean {
const normalize = (value: string) => platform === 'win32' ? resolve(value).toLowerCase() : resolve(value);
const offset = relative(normalize(directory), normalize(path));
return offset === '' || (!offset.startsWith('..') && !isAbsolute(offset));
}
/**
* The archive holds complete technical customer data, so it must stay outside the repository,
* the served application and synchronized OneDrive folders.
*/
export function resolveReportArchiveRoot(
environment: ReportArchiveEnvironment,
forbiddenRoots: string[],
platform: NodeJS.Platform = process.platform,
home: string = homedir()
): string {
const configured = environment.ASSESSOR_REPORT_ARCHIVE_DIR?.trim();
if (configured && !isAbsolute(configured)) throw new Error('ASSESSOR_REPORT_ARCHIVE_DIR muss ein absoluter Pfad sein.');
const root = resolve(configured || defaultArchiveRoot(environment, platform, home));
const synced = [environment.OneDrive, environment.OneDriveCommercial, environment.OneDriveConsumer]
.filter((value): value is string => Boolean(value?.trim()));
if (forbiddenRoots.some(directory => inside(root, directory, platform))) {
throw new Error('Das Report-Archiv darf nicht im Repository oder im ausgelieferten Anwendungsordner liegen.');
}
if (synced.some(directory => inside(root, directory, platform))
|| root.split(/[\\/]/).some(segment => /^OneDrive(?:\s-\s.+)?$/i.test(segment))) {
throw new Error('Das Report-Archiv darf nicht in einem synchronisierten OneDrive-Ordner liegen.');
}
return root;
}