Files
FireAdministration/src/lib/storage.ts
Florian WildeandClaude Opus 5 845942f36c Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen.

Betrieb:
- docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS,
  Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer
  mit Mailpit und Adminer
- Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck
- .env.example mit einem Kommentar zu jeder Variable

Mandantentrennung:
- Row Level Security auf allen mandantenpflichtigen Tabellen
- Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer
  die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung
- fuerOrganisation() als einziger Weg zu Fachdaten

Nachvollziehbarkeit:
- AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE
  nur im gekennzeichneten Aufbewahrungslauf
- Geheimnisse werden vor dem Schreiben entfernt

Anmeldung:
- OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als
  Vorbelegung beim ersten Login
- Lokaler E-Mail/Passwort-Login als Rueckfallweg
- Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person)

Weiteres:
- lib/authz.ts mit 9 Rollen und 38 Einzelrechten
- lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ)
- Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces
- tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete
- 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests
- README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog,
  Versionierungsregeln und Changelog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:13:53 +02:00

210 lines
6.3 KiB
TypeScript

import "server-only";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { Client as MinioClient } from "minio";
import { env } from "@/lib/env";
/**
* Der einzige Weg zu Dateien.
*
* Regel aus CLAUDE.md: Dateizugriff ausschließlich über lib/storage.ts. Kein
* fs.readFile, kein Pfad im Dateisystem, keine Blobs in der Datenbank. Alles
* liegt in MinIO — dadurch überlebt eine Datei den Neuaufbau des Containers,
* lässt sich versionieren und getrennt sichern.
*
* Objektschlüssel folgen immer demselben Muster:
* <organisation-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
* Der Slug vorn macht Sicherungen und Löschungen je Wehr einfach.
*/
let clientCache: MinioClient | null = null;
function client(): MinioClient {
if (!clientCache) {
clientCache = new MinioClient({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_USE_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
});
}
return clientCache;
}
export const BUCKET = env.S3_BUCKET;
/** Zeichen, die in Objektschlüsseln Ärger machen, werden ersetzt. */
function dateinameBereinigen(name: string): string {
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/ä/gi, "ae")
.replace(/ö/gi, "oe")
.replace(/ü/gi, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_{2,}/g, "_")
.slice(0, 180);
}
export interface SchluesselTeile {
organisationSlug: string;
/** Modellname, z. B. "Geraet". Wird kleingeschrieben. */
entitaet: string;
entitaetId?: string;
dateiname: string;
}
/** Baut einen eindeutigen Objektschlüssel nach dem oben beschriebenen Muster. */
export function objektSchluessel({
organisationSlug,
entitaet,
entitaetId,
dateiname,
}: SchluesselTeile): string {
const teile = [
organisationSlug,
entitaet.toLowerCase(),
entitaetId ?? "allgemein",
`${randomUUID()}-${dateinameBereinigen(dateiname)}`,
];
return teile.join("/");
}
export interface HochladenErgebnis {
bucket: string;
objektSchluessel: string;
groesseBytes: number;
/** SHA-256 des Inhalts — erkennt Dubletten, belegt Unverändertheit. */
pruefsumme: string;
etag: string;
}
/**
* Legt einen Puffer in MinIO ab und gibt zurück, was in die Datei-Tabelle
* gehört. Der Aufrufer schreibt den Datensatz und den Audit-Eintrag.
*/
export async function hochladen(
schluessel: string,
inhalt: Buffer,
mimeTyp: string,
metadaten: Record<string, string> = {},
): Promise<HochladenErgebnis> {
await bucketSicherstellen();
const pruefsumme = createHash("sha256").update(inhalt).digest("hex");
const ergebnis = await client().putObject(BUCKET, schluessel, inhalt, inhalt.length, {
"Content-Type": mimeTyp,
// MinIO nimmt eigene Angaben nur mit diesem Präfix an.
"x-amz-meta-pruefsumme": pruefsumme,
...Object.fromEntries(
Object.entries(metadaten).map(([schlssel, wert]) => [`x-amz-meta-${schlssel}`, wert]),
),
});
return {
bucket: BUCKET,
objektSchluessel: schluessel,
groesseBytes: inhalt.length,
pruefsumme,
etag: ergebnis.etag,
};
}
/** Lädt ein Objekt vollständig in den Speicher. Für große Dateien streamen. */
export async function herunterladen(schluessel: string): Promise<Buffer> {
const strom = await client().getObject(BUCKET, schluessel);
const stuecke: Buffer[] = [];
for await (const stueck of strom) {
stuecke.push(Buffer.isBuffer(stueck) ? stueck : Buffer.from(stueck));
}
return Buffer.concat(stuecke);
}
/** Objekt als Strom — für Downloads, die durch die Anwendung durchgereicht werden. */
export async function alsStrom(schluessel: string): Promise<Readable> {
return client().getObject(BUCKET, schluessel);
}
/**
* Zeitlich begrenzter Download-Link. Damit muss die Datei nicht durch die
* Anwendung laufen — der Browser holt sie direkt bei MinIO ab.
* Der Link gilt S3_URL_TTL Sekunden und ist danach wertlos.
*/
export async function downloadLink(
schluessel: string,
dateiname?: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
const kopfzeilen: Record<string, string> = {};
if (dateiname) {
kopfzeilen["response-content-disposition"] =
`attachment; filename="${dateinameBereinigen(dateiname)}"`;
}
return client().presignedGetObject(BUCKET, schluessel, gueltigkeitSekunden, kopfzeilen);
}
/**
* Zeitlich begrenzter Upload-Link. Der Browser lädt direkt zu MinIO hoch,
* ohne dass große Dateien durch die Anwendung müssen.
*/
export async function uploadLink(
schluessel: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
await bucketSicherstellen();
return client().presignedPutObject(BUCKET, schluessel, gueltigkeitSekunden);
}
/** Löscht ein Objekt endgültig. Nur für den Aufbewahrungs-Job des Workers. */
export async function endgueltigLoeschen(schluessel: string): Promise<void> {
await client().removeObject(BUCKET, schluessel);
}
/** Gibt es das Objekt und wie groß ist es? */
export async function objektInfo(schluessel: string) {
try {
const status = await client().statObject(BUCKET, schluessel);
return {
vorhanden: true as const,
groesseBytes: status.size,
geaendertAm: status.lastModified,
mimeTyp: status.metaData?.["content-type"] ?? "application/octet-stream",
etag: status.etag,
};
} catch {
return { vorhanden: false as const };
}
}
/** Kopiert ein Objekt, z. B. beim Duplizieren einer Prüfvorlage. */
export async function kopieren(vonSchluessel: string, nachSchluessel: string): Promise<void> {
await client().copyObject(BUCKET, nachSchluessel, `/${BUCKET}/${vonSchluessel}`);
}
/** Legt den Bucket an, falls er fehlt. Idempotent, deshalb überall aufrufbar. */
export async function bucketSicherstellen(): Promise<void> {
const vorhanden = await client().bucketExists(BUCKET).catch(() => false);
if (!vorhanden) {
await client().makeBucket(BUCKET, env.S3_REGION);
}
}
/** Erreichbarkeitsprüfung für /api/health. */
export async function speicherErreichbar(): Promise<boolean> {
try {
await client().bucketExists(BUCKET);
return true;
} catch {
return false;
}
}
/** Nur für Tests: setzt den zwischengespeicherten Client zurück. */
export function _clientZuruecksetzen(): void {
clientCache = null;
}