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>
210 lines
6.3 KiB
TypeScript
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;
|
|
}
|