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