feat: marketplace modules and isolated container management
This commit is contained in:
28
.env.example
28
.env.example
@@ -22,17 +22,39 @@ DATABASE_URL=postgresql://mpm:<sicheres-passwort>@postgres:5432/mpm
|
|||||||
# Session-Gültigkeit in Minuten (kurz halten)
|
# Session-Gültigkeit in Minuten (kurz halten)
|
||||||
SESSION_TTL_MINUTES=120
|
SESSION_TTL_MINUTES=120
|
||||||
# "true" sobald die Plattform hinter HTTPS/TLS betrieben wird
|
# "true" sobald die Plattform hinter HTTPS/TLS betrieben wird
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=true
|
||||||
# "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist
|
# "true", wenn ein Reverse Proxy (Nginx im Container) vorgeschaltet ist
|
||||||
BEHIND_PROXY=true
|
BEHIND_PROXY=true
|
||||||
|
|
||||||
|
# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0).
|
||||||
|
# MPM braucht den Socket, um eigene Modul-Container zu verwalten.
|
||||||
|
DOCKER_SOCKET_GID=0
|
||||||
|
|
||||||
|
# GID der Docker-Socket-Gruppe auf dem Host (Docker Desktop meist 0).
|
||||||
|
# MPM benötigt den Docker-Socket, um Modul-Stacks zu verwalten.
|
||||||
|
DOCKER_SOCKET_GID=0
|
||||||
|
|
||||||
# --- Initialer Admin (nur beim ersten Start angelegt) -----------
|
# --- Initialer Admin (nur beim ersten Start angelegt) -----------
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=<mindestens-10-zeichen>
|
ADMIN_PASSWORD=<mindestens-10-zeichen>
|
||||||
|
|
||||||
|
# --- Marketplace OAuth (optional) --------------------------------
|
||||||
|
# Lokal: Host-Adresse einschließlich APP_PORT; Callback-Pfad wird von MPM ergänzt.
|
||||||
|
MARKETPLACE_PUBLIC_URL=http://127.0.0.1:8080
|
||||||
|
# Zufälliger, dauerhafter Wert (mindestens 32 Zeichen), z. B. openssl rand -base64 32.
|
||||||
|
MARKETPLACE_TOKEN_ENCRYPTION_KEY=
|
||||||
|
# OAuth-App Callback: http://127.0.0.1:8080/api/v1/marketplace/oauth/github/callback
|
||||||
|
GITHUB_OAUTH_CLIENT_ID=
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET=
|
||||||
|
# Für eine selbst gehostete Instanz jeweils alle drei Werte setzen.
|
||||||
|
GITEA_BASE_URL=
|
||||||
|
GITEA_OAUTH_CLIENT_ID=
|
||||||
|
GITEA_OAUTH_CLIENT_SECRET=
|
||||||
|
FORGEJO_BASE_URL=
|
||||||
|
FORGEJO_OAUTH_CLIENT_ID=
|
||||||
|
FORGEJO_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# --- Login-Schutz (optional, mit Defaults) ----------------------
|
# --- Login-Schutz (optional, mit Defaults) ----------------------
|
||||||
# LOGIN_MAX_ATTEMPTS=5
|
|
||||||
# LOGIN_LOCKOUT_MINUTES=15
|
|
||||||
# LOGIN_RATE_LIMIT_ATTEMPTS=10
|
# LOGIN_RATE_LIMIT_ATTEMPTS=10
|
||||||
# LOGIN_RATE_LIMIT_WINDOW_MINUTES=5
|
# LOGIN_RATE_LIMIT_WINDOW_MINUTES=5
|
||||||
18
Dockerfile
18
Dockerfile
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
# =============================================================
|
# =============================================================
|
||||||
# MPM – Management-Container
|
# MPM – Management-Container
|
||||||
# Ein einzelner Container, der als unprivilegierter Benutzer
|
# Single container with a restricted-capability root supervisor.
|
||||||
# folgende Prozesse verwaltet (via Supervisor):
|
# Backend, Nginx workers, and modules otherwise run unprivileged:
|
||||||
# - Nginx (Reverse Proxy, Port 8080)
|
# - Nginx (Reverse Proxy, Port 8080)
|
||||||
# - NestJS Management-Backend (127.0.0.1:3000)
|
# - NestJS Management-Backend (127.0.0.1:3000)
|
||||||
# Ab Phase 3 laufen hier zusätzlich die Modul-Prozesse.
|
# Ab Phase 3 laufen hier zusätzlich die Modul-Prozesse.
|
||||||
@@ -19,22 +19,26 @@ COPY apps/platform-frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---------- Backend-Build ----------
|
# ---------- Backend-Build ----------
|
||||||
|
FROM node:24-slim AS backend-runtime-deps
|
||||||
|
WORKDIR /build
|
||||||
|
COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
FROM node:24-slim AS backend-build
|
FROM node:24-slim AS backend-build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./
|
COPY apps/platform-backend/package.json apps/platform-backend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY apps/platform-backend/ ./
|
COPY apps/platform-backend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
RUN npm prune --omit=dev
|
|
||||||
|
|
||||||
# ---------- Laufzeit-Image ----------
|
# ---------- Laufzeit-Image ----------
|
||||||
FROM node:24-slim AS runtime
|
FROM node:24-slim AS runtime
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends nginx supervisor \
|
&& apt-get install -y --no-install-recommends nginx supervisor util-linux docker.io docker-compose \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Unprivilegierter Benutzer für alle Prozesse im Container
|
# Unprivileged service account for backend and module processes
|
||||||
RUN groupadd --gid 1001 app \
|
RUN groupadd --gid 1001 app \
|
||||||
&& useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \
|
&& useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \
|
||||||
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \
|
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \
|
||||||
@@ -44,11 +48,11 @@ COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
|||||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
|
COPY --from=backend-build --chown=app:app /build/dist /app/platform-backend/dist
|
||||||
COPY --from=backend-build --chown=app:app /build/node_modules /app/platform-backend/node_modules
|
COPY --from=backend-runtime-deps --chown=app:app /build/node_modules /app/platform-backend/node_modules
|
||||||
COPY --from=backend-build --chown=app:app /build/package.json /app/platform-backend/package.json
|
COPY --from=backend-build --chown=app:app /build/package.json /app/platform-backend/package.json
|
||||||
COPY --from=frontend-build --chown=app:app /build/dist /app/public
|
COPY --from=frontend-build --chown=app:app /build/dist /app/public
|
||||||
|
|
||||||
USER app
|
USER root
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
|||||||
113
KI_SETUP.md
Normal file
113
KI_SETUP.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Arbeitsplatz-Setup für KI-Assistenten
|
||||||
|
|
||||||
|
Diese Datei beschreibt, wie MPM auf einem neuen Arbeitsplatz eingerichtet und angepasst wird. Lies sie zusammen mit `README.md`, `docs/ARCHITECTURE.md` und `docs/MODULE-MARKETPLACE.md`, bevor du Änderungen vornimmst.
|
||||||
|
|
||||||
|
## Projektziel und Architektur
|
||||||
|
|
||||||
|
MPM ist eine Management-Plattform für Benutzer, Rollen und externe Module. Das Frontend ist React/Vite/TypeScript, das Backend NestJS/TypeScript, die Plattformdaten liegen in PostgreSQL. Die Plattform wird mit Docker Compose gestartet. Installierte Module laufen in eigenen Compose-Projekten mit eigenen Containern und persistenten Volumes; MPM verwaltet deren Start, Stop, Health, Disable und Remove. Der Docker-Socket wird nur vom MPM-Backend verwendet und niemals an Modulcontainer weitergereicht.
|
||||||
|
|
||||||
|
Wichtige Verzeichnisse:
|
||||||
|
|
||||||
|
- `apps/platform-frontend`: React-Oberfläche
|
||||||
|
- `apps/platform-backend`: API, Authentifizierung, Marketplace und Modulverwaltung
|
||||||
|
- `packages/platform-module-sdk`: SDK-Vertrag für Module
|
||||||
|
- `modules/demo`: Referenzmodul
|
||||||
|
- `docker`: Nginx- und Supervisor-Konfiguration
|
||||||
|
- `docs`: Architektur und Modul-/Marketplace-Verträge
|
||||||
|
|
||||||
|
Änderungen an Modulinstallation oder Container-Lifecycle müssen den Compose-Stack, persistente Daten, Gateway-Netzwerk und Fehler-/Recovery-Pfade berücksichtigen. Lies dafür `module-container-manager.ts`, `module-installer.ts`, `modules.service.ts` sowie die Modul-Dokumentation.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Für den üblichen Betrieb:
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- Docker Desktop mit Linux-Containern und Docker Compose v2; unter Windows ist ein aktiviertes WSL2-Backend empfehlenswert
|
||||||
|
- VS Code und ein KI-Assistent mit Zugriff auf den geöffneten Projektordner
|
||||||
|
|
||||||
|
Für lokale Frontend-/Backend-Entwicklung außerhalb von Docker zusätzlich Node.js 24 und npm. Die Module haben eigene Laufzeit- und Build-Anforderungen gemäß ihrem Manifest.
|
||||||
|
|
||||||
|
## Beim ersten Öffnen auf einem neuen Arbeitsplatz
|
||||||
|
|
||||||
|
1. Repository auschecken und den aktuellen Arbeitsbranch verwenden. Den vorhandenen Branch nicht ungefragt auf `main` umbenennen oder lokale Änderungen verwerfen.
|
||||||
|
2. `git status --short --branch` prüfen. Nicht committete Änderungen gehören möglicherweise dem Nutzer; niemals resetten, stashen, überschreiben oder entfernen, ohne vorher genau zu prüfen.
|
||||||
|
3. Docker Desktop starten und sicherstellen, dass Linux-Container und Compose v2 funktionieren.
|
||||||
|
4. `.env` nur anlegen, wenn sie noch nicht vorhanden ist: `.env.example` kopieren und ausschließlich lokale Entwicklungswerte eintragen. Eine vorhandene `.env` nie ersetzen oder ausgeben.
|
||||||
|
5. Secrets dieses Arbeitsplatzes getrennt halten. Keine Passwörter, OAuth-Secrets, Zugriffstokens, Cookies oder privaten Schlüssel in Quellcode, Dokumentation, Kommandoausgaben, Commits oder Issues übernehmen. Beispielwerte in `.env.example` sind Platzhalter.
|
||||||
|
6. Bei rein lokaler HTTP-Entwicklung `NODE_ENV=development` und `COOKIE_SECURE=false` verwenden. In Produktion muss HTTPS aktiv sein und `COOKIE_SECURE=true` gesetzt werden.
|
||||||
|
7. Für OAuth-Entwicklung sind pro Provider eigene OAuth-Clientdaten mit passender Callback-URL nötig. Ohne OAuth-Konfiguration können die übrigen Plattformfunktionen lokal verwendet werden; Provider dürfen nicht mit unvollständiger Konfiguration gesetzt werden. Bei aktivem OAuth einen dauerhaften `MARKETPLACE_TOKEN_ENCRYPTION_KEY` mit mindestens 32 Zeichen lokal generieren und geheim halten.
|
||||||
|
8. `APP_PORT` bei Bedarf anpassen, falls 8080 belegt ist. `MARKETPLACE_PUBLIC_URL` muss die vom Browser erreichbare Basisadresse samt Port enthalten, etwa `http://127.0.0.1:8080`.
|
||||||
|
9. `DOCKER_SOCKET_GID` ist hostabhängig. Docker Desktop verwendet häufig `0`; bei Linux ist die tatsächliche Gruppe des Docker-Sockets zu verwenden. Änderungen daran erst nach Prüfung der Docker-Berechtigungen vornehmen.
|
||||||
|
|
||||||
|
Die Datenbankverbindung innerhalb des Compose-Netzwerks verwendet den Hostnamen `postgres`. Bei Backend-Ausführung direkt auf dem Host muss `DATABASE_URL` auf `127.0.0.1:5432` zeigen. Niemals den Compose-internen Hostnamen `postgres` für einen Backendprozess auf dem Host verwenden.
|
||||||
|
|
||||||
|
## Plattform mit Docker starten
|
||||||
|
|
||||||
|
Im Projektstamm:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach die in `APP_PORT` konfigurierte Adresse öffnen (Standard `http://localhost:8080`). Das initiale Admin-Konto wird beim ersten Datenbankstart aus `ADMIN_USERNAME`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` angelegt. Spätere Änderungen dieser Variablen ändern ein bereits angelegtes Datenbankkonto nicht automatisch.
|
||||||
|
|
||||||
|
Logs und Neustart:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose logs -f platform
|
||||||
|
docker compose logs -f postgres
|
||||||
|
docker compose restart platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Für normale Neustarts oder Updates kein `docker compose down -v` verwenden: `-v` löscht persistente Datenbank- und Moduldaten.
|
||||||
|
|
||||||
|
## Lokale Entwicklung ohne Plattform-Container
|
||||||
|
|
||||||
|
PostgreSQL zuerst starten:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in separaten Terminals:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location apps/platform-backend
|
||||||
|
npm ci
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location apps/platform-frontend
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Backendprozess benötigt gültige Variablen aus `.env`; beim lokalen Start muss `DATABASE_URL` auf `127.0.0.1` zeigen. Falls die Entwicklungsumgebung `.env` nicht automatisch lädt, Variablen sicher über die lokale Shell/VS-Code-Konfiguration einlesen; keine Secrets in Startskripte committieren.
|
||||||
|
|
||||||
|
## Arbeitsregeln für Änderungen
|
||||||
|
|
||||||
|
- Vor Änderungen relevante `AGENTS.md`-Dateien, Dokumentation und betroffene Implementierungen lesen.
|
||||||
|
- Vor jedem Commit Status und Diff prüfen. Nur die beabsichtigten Dateien aufnehmen; Nutzeränderungen nicht stillschweigend verwerfen.
|
||||||
|
- Keine echten Zugangsdaten in Ausgaben oder Dokumentation schreiben. Wenn ein Secret versehentlich in einen Commit gelangt ist, es als kompromittiert behandeln und rotieren; bloßes Löschen aus der aktuellen Datei reicht nicht.
|
||||||
|
- API-Änderungen auf DTO/Validierung, Authentifizierung, RBAC, CSRF, Audit und Frontend-Verwendung prüfen.
|
||||||
|
- Containeränderungen auf Windows/Docker Desktop und Linux, Restart/Stop/Remove, Netzwerk-Neuerstellung, Datenpersistenz und Logs prüfen.
|
||||||
|
- Datenbankänderungen als neue Migration ergänzen; bestehende Migrationen nicht nachträglich umschreiben, wenn sie schon angewendet sein könnten.
|
||||||
|
- UI-Änderungen an bestehenden Komponenten und Dark-/Light-Theme-Konventionen ausrichten.
|
||||||
|
- Abhängigkeiten nur bei Bedarf ändern und Lockfiles konsistent halten.
|
||||||
|
- Keine Builds, Tests, Deployments, Commits oder Pushes ausführen, wenn der Nutzer das nicht angefordert hat. Wenn er Verifikation verlangt, die tatsächlich ausgeführten Befehle und Ergebnisse angeben.
|
||||||
|
- Bei einem gewünschten Push Ziel-Remote und Branch verifizieren, den kompletten Commit-Diff auf Secrets prüfen und keine Force-Pushes ausführen, außer der Nutzer weist sie ausdrücklich an.
|
||||||
|
|
||||||
|
## Häufige Arbeitsplatzprobleme
|
||||||
|
|
||||||
|
- **Port belegt:** `APP_PORT` in `.env` ändern und `MARKETPLACE_PUBLIC_URL` synchron anpassen. PostgreSQL-Port 5432 kann für reine Compose-Nutzung bei Bedarf ebenfalls hostseitig angepasst werden; dann muss die lokale `DATABASE_URL` mitziehen.
|
||||||
|
- **Backend startet nicht:** Prüfen, ob alle Pflichtvariablen gesetzt sind, `ADMIN_PASSWORD` mindestens 10 Zeichen hat, `DATABASE_URL` den richtigen Host verwendet und PostgreSQL gesund ist.
|
||||||
|
- **OAuth-Callback schlägt fehl:** Externe Callback-URL muss exakt zur Provider-Konfiguration passen, inklusive Schema, Host, Port und Pfad `/api/v1/marketplace/oauth/<provider>/callback`. Redirect-URL und `MARKETPLACE_PUBLIC_URL` müssen übereinstimmen.
|
||||||
|
- **Modulcontainer lassen sich nicht steuern:** Docker Desktop muss laufen; Socket-Mount und `DOCKER_SOCKET_GID` prüfen. Docker-Socket-Zugriff ist privilegiert; keine Erhöhung für Modulcontainer aktivieren.
|
||||||
|
- **Modul kann nach Stop nicht starten:** MPM-Logs und den Modul-Compose-Stack prüfen; Containerstatus, Gateway-Netz und Volume-Status erfassen. Nicht als Erstes Datenvolumes löschen.
|
||||||
|
- **Windows-Dateirechte oder Pfade:** Docker Compose läuft in Linux-Containern; Datei- und Socketpfade aus Docker Desktop/WSL berücksichtigen und möglichst nicht zwischen Windows- und WSL-Dateisystemen hin- und herkopieren.
|
||||||
|
|
||||||
|
## Dokumente zum Modulvertrag
|
||||||
|
|
||||||
|
Vor Implementierung oder Anpassung eines Marketplace-Moduls außerdem `docs/MODULE-MARKETPLACE.md`, `modules/README.md` und `packages/platform-module-sdk/README.md` lesen. Das Modulmanifest und dessen Compose-/Health-/Datenvolume-Angaben sind Teil der Integrationsschnittstelle.
|
||||||
17
README.md
17
README.md
@@ -10,7 +10,7 @@ Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applika
|
|||||||
Internet
|
Internet
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Docker Container (mpm-platform, unprivilegierter Benutzer "app")
|
Docker container (restricted-capability root supervisor; services run as app)
|
||||||
┌─────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐
|
||||||
│ Supervisor (Prozessmanager) │
|
│ Supervisor (Prozessmanager) │
|
||||||
│ ├── Nginx (Reverse Proxy, :8080) │
|
│ ├── Nginx (Reverse Proxy, :8080) │
|
||||||
@@ -24,8 +24,9 @@ Docker Container (mpm-platform, unprivilegierter Benutzer "app")
|
|||||||
PostgreSQL (eigener Container, persistentes Volume)
|
PostgreSQL (eigener Container, persistentes Volume)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Ein** Applikationscontainer, **kein** Docker-in-Docker, **kein** Docker-Socket.
|
- Der MPM-Managementcontainer verwaltet Modul-Stacks über den Docker-Socket. Modulcode erhält selbst keinen Socketzugriff.
|
||||||
- Module laufen ab Phase 3 als interne Prozesse im selben Container (eigene Ports, nur über den Reverse Proxy erreichbar).
|
- Neue Module laufen in eigenen Compose-Stacks. Die App und optionale Datenbanken haben getrennte Container und persistente Volumes.
|
||||||
|
- Der Docker-Socket ermöglicht weitreichende Hoststeuerung. Daher dürfen nur vertrauenswürdige Administratoren Module installieren; der Socket wird nie in Modulcontainer durchgereicht.
|
||||||
- PostgreSQL liegt außerhalb des Applikationscontainers in einem persistenten Volume.
|
- PostgreSQL liegt außerhalb des Applikationscontainers in einem persistenten Volume.
|
||||||
|
|
||||||
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES.md`](docs/PHASES.md)
|
Details: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · Phasen: [`docs/PHASES.md`](docs/PHASES.md)
|
||||||
@@ -76,7 +77,7 @@ MPM/
|
|||||||
├── docs/ # Architektur- & Phasen-Dokumentation
|
├── docs/ # Architektur- & Phasen-Dokumentation
|
||||||
├── modules/ # Installierbare Module (ab Phase 3)
|
├── modules/ # Installierbare Module (ab Phase 3)
|
||||||
├── Dockerfile # Multi-Stage-Build des Management-Containers
|
├── Dockerfile # Multi-Stage-Build des Management-Containers
|
||||||
└── docker-compose.yml # PostgreSQL + Management-Container
|
└── docker-compose.yml # PostgreSQL + MPM; Module erhalten eigene Compose-Stacks
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
@@ -86,19 +87,19 @@ MPM/
|
|||||||
| Frontend | React 19, TypeScript, Vite, Tailwind CSS, React Router, TanStack Query, Zod |
|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS, React Router, TanStack Query, Zod |
|
||||||
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
|
| Backend | NestJS 11, TypeScript, REST `/api/v1`, OpenAPI/Swagger |
|
||||||
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
|
| Datenbank | PostgreSQL 18 (Schemas: `management`, ab Phase 3 pro Modul) |
|
||||||
| Betrieb | Docker, Nginx, Supervisor, unprivilegierter Benutzer |
|
| Betrieb | Docker Compose, Nginx, MPM-Managementcontainer und separate Modul-Stacks |
|
||||||
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, Rate Limiting, Account Lockout, Audit-Log, Helmet, RBAC |
|
| Sicherheit | Argon2id, HttpOnly/Secure/SameSite-Cookies, serverseitige Sessions, CSRF-Schutz, IP-basiertes Rate Limiting, Audit-Log, Helmet, RBAC |
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
### Phase 1 – Grundgerüst
|
### Phase 1 – Grundgerüst
|
||||||
Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System.
|
Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), IP-basiertes Rate Limiting, Health-Monitoring, Audit-Log, Migrationen mit Advisory-Lock, responsive Management-UI mit Design-System.
|
||||||
|
|
||||||
### Phase 2 – Benutzerverwaltung
|
### Phase 2 – Benutzerverwaltung
|
||||||
Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite.
|
Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite.
|
||||||
|
|
||||||
### Phase 3 – Modul-System
|
### Phase 3 – Modul-System
|
||||||
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, Prozess-Manager (Kindprozesse mit minimaler ENV, eigene Logs), Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Volumes für Modul-Dateien.
|
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, eigene Compose-Stacks je Modul, Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Datenvolumes.
|
||||||
|
|
||||||
### Phase 4 – Gateway & Routing
|
### Phase 4 – Gateway & Routing
|
||||||
Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts.
|
Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts.
|
||||||
|
|||||||
22
apps/platform-backend/package-lock.json
generated
22
apps/platform-backend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -6618,9 +6619,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "5.3.0",
|
"version": "5.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
|
||||||
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
|
"integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9544,6 +9545,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.3",
|
"version": "17.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,5 +50,10 @@
|
|||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"typescript-eslint": "^8.0.0"
|
"typescript-eslint": "^8.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@nestjs/swagger": {
|
||||||
|
"js-yaml": "5.4.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,20 +55,21 @@ export class AuthController {
|
|||||||
ipAddress: request.ip ?? null,
|
ipAddress: request.ip ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cookieMaxAgeSeconds = this.config.security.sessionTtlMinutes * 60;
|
// Express expects cookie maxAge in milliseconds (the DB TTL is in minutes).
|
||||||
|
const cookieMaxAgeMs = this.config.security.sessionTtlMinutes * 60 * 1000;
|
||||||
response.cookie('mpm_session', result.sessionToken, {
|
response.cookie('mpm_session', result.sessionToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.config.security.cookieSecure,
|
secure: this.config.security.cookieSecure,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: cookieMaxAgeSeconds,
|
maxAge: cookieMaxAgeMs,
|
||||||
});
|
});
|
||||||
response.cookie('mpm_csrf', result.session.csrfToken, {
|
response.cookie('mpm_csrf', result.session.csrfToken, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: this.config.security.cookieSecure,
|
secure: this.config.security.cookieSecure,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: cookieMaxAgeSeconds,
|
maxAge: cookieMaxAgeMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: toAuthUserResponse(result.user) };
|
return { user: toAuthUserResponse(result.user) };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
|
|||||||
},
|
},
|
||||||
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
||||||
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
||||||
|
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
|
|||||||
class MockUserRepository {
|
class MockUserRepository {
|
||||||
public findByUsernameResult: UserRecord | null = null;
|
public findByUsernameResult: UserRecord | null = null;
|
||||||
public updateLoginSuccessCalls: string[] = [];
|
public updateLoginSuccessCalls: string[] = [];
|
||||||
public updateLoginFailureCalls: Array<{ userId: string; attempts: number; shouldLock: boolean; lockoutMinutes: number }> = [];
|
public updateLoginFailureCalls: string[] = [];
|
||||||
|
|
||||||
async findByUsername(): Promise<UserRecord | null> {
|
async findByUsername(): Promise<UserRecord | null> {
|
||||||
return this.findByUsernameResult;
|
return this.findByUsernameResult;
|
||||||
@@ -62,8 +63,8 @@ class MockUserRepository {
|
|||||||
this.updateLoginSuccessCalls.push(userId);
|
this.updateLoginSuccessCalls.push(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLoginFailure(userId: string, attempts: number, shouldLock: boolean, lockoutMinutes: number): Promise<void> {
|
async updateLoginFailure(userId: string): Promise<void> {
|
||||||
this.updateLoginFailureCalls.push({ userId, attempts, shouldLock, lockoutMinutes });
|
this.updateLoginFailureCalls.push(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +159,12 @@ describe('AuthService', () => {
|
|||||||
).rejects.toThrow(UnauthorizedException);
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
|
||||||
expect(userRepository.updateLoginFailureCalls).toEqual([
|
expect(userRepository.updateLoginFailureCalls).toEqual([
|
||||||
{ userId: 'user-1', attempts: 1, shouldLock: false, lockoutMinutes: 15 },
|
'user-1',
|
||||||
]);
|
]);
|
||||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sperrt das Konto nach Erreichen der maximalen Fehlversuche', async () => {
|
it('verhindert Loginversuche nicht durch Kontosperren', async () => {
|
||||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||||
userRepository.findByUsernameResult = createUserRecord({
|
userRepository.findByUsernameResult = createUserRecord({
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -175,9 +176,9 @@ describe('AuthService', () => {
|
|||||||
).rejects.toThrow(UnauthorizedException);
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
|
||||||
expect(userRepository.updateLoginFailureCalls).toEqual([
|
expect(userRepository.updateLoginFailureCalls).toEqual([
|
||||||
{ userId: 'user-1', attempts: 3, shouldLock: true, lockoutMinutes: 15 },
|
'user-1',
|
||||||
]);
|
]);
|
||||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_LOCKED);
|
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lehnt gesperrte Benutzer ab', async () => {
|
it('lehnt gesperrte Benutzer ab', async () => {
|
||||||
@@ -187,11 +188,12 @@ describe('AuthService', () => {
|
|||||||
lockedUntil: new Date(Date.now() + 60_000),
|
lockedUntil: new Date(Date.now() + 60_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const result = await authService.login({
|
||||||
authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' },
|
username: 'max',
|
||||||
)).rejects.toThrow(UnauthorizedException);
|
password: 'Sicheres-Passwort-1',
|
||||||
|
ipAddress: '127.0.0.1',
|
||||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_LOCKED' });
|
});
|
||||||
|
expect(result.user.username).toBe('max');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lehnt deaktivierte Benutzer ab', async () => {
|
it('lehnt deaktivierte Benutzer ab', async () => {
|
||||||
@@ -226,7 +228,7 @@ describe('AuthService', () => {
|
|||||||
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' });
|
expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setzt das Rate-Limit-Fenster nach erfolgreichem Login zurück', async () => {
|
it('setzt das Rate-Limit-Fenster nach erfolgreichem Login nicht zurück', async () => {
|
||||||
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1');
|
||||||
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
userRepository.findByUsernameResult = createUserRecord({ passwordHash });
|
||||||
|
|
||||||
@@ -243,13 +245,11 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
expect(result.user.username).toBe('max');
|
expect(result.user.username).toBe('max');
|
||||||
|
|
||||||
// Nach Reset ist ein neuer Login sofort wieder möglich.
|
await expect(authService.login({
|
||||||
const secondResult = await authService.login({
|
|
||||||
username: 'max',
|
username: 'max',
|
||||||
password: 'Sicheres-Passwort-1',
|
password: 'Sicheres-Passwort-1',
|
||||||
ipAddress: '127.0.0.1',
|
ipAddress: '127.0.0.1',
|
||||||
});
|
})).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
||||||
expect(secondResult.user.username).toBe('max');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const INVALID_CREDENTIALS_MESSAGE = 'Benutzername oder Passwort ist falsch';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentifizierungs-Logik (Domain/Application):
|
* Authentifizierungs-Logik (Domain/Application):
|
||||||
* Login mit Rate Limiting, Account Lockout, Argon2id-Verifikation,
|
* Login mit IP-basiertem Rate Limiting, Argon2id-Verifikation,
|
||||||
* Session-Erstellung und Audit-Logging.
|
* Session-Erstellung und Audit-Logging.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -72,32 +72,14 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password);
|
||||||
|
if (!passwordValid) {
|
||||||
|
await this.userRepository.updateLoginFailure(user.id);
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
details: { reason: 'ACCOUNT_LOCKED' },
|
details: { reason: 'INVALID_PASSWORD' },
|
||||||
ipAddress: input.ipAddress,
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordValid = await this.passwordHasher.verify(user.passwordHash, input.password);
|
|
||||||
if (!passwordValid) {
|
|
||||||
const attempts = user.failedLoginAttempts + 1;
|
|
||||||
const shouldLock = attempts >= security.loginMaxAttempts;
|
|
||||||
await this.userRepository.updateLoginFailure(
|
|
||||||
user.id,
|
|
||||||
attempts,
|
|
||||||
shouldLock,
|
|
||||||
security.loginLockoutMinutes,
|
|
||||||
);
|
|
||||||
await this.auditService.record({
|
|
||||||
userId: user.id,
|
|
||||||
username: user.username,
|
|
||||||
action: shouldLock ? 'LOGIN_FAILED_LOCKED' : 'LOGIN_FAILED',
|
|
||||||
details: { reason: 'INVALID_PASSWORD', attempts },
|
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
throw new UnauthorizedException(INVALID_CREDENTIALS_MESSAGE);
|
||||||
@@ -115,7 +97,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.userRepository.updateLoginSuccess(user.id);
|
await this.userRepository.updateLoginSuccess(user.id);
|
||||||
this.rateLimiter.reset(rateLimitKey);
|
|
||||||
|
|
||||||
const { token, data } = await this.sessionService.create(
|
const { token, data } = await this.sessionService.create(
|
||||||
user.id,
|
user.id,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|||||||
*
|
*
|
||||||
* Requests ohne Session (z. B. Login) sind ausgenommen: Sie besitzen
|
* Requests ohne Session (z. B. Login) sind ausgenommen: Sie besitzen
|
||||||
* kein Session-CSRF-Token. Das Login ist stattdessen durch Rate
|
* kein Session-CSRF-Token. Das Login ist stattdessen durch Rate
|
||||||
* Limiting, Account Lockout und SameSite=Lax-Cookies geschützt.
|
* Limiting und SameSite=Lax-Cookies geschützt.
|
||||||
* Ungültige Sessions werden bereits vom SessionGuard mit 401 abgewiesen.
|
* Ungültige Sessions werden bereits vom SessionGuard mit 401 abgewiesen.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ export function extractSessionToken(request: RequestWithCookieHeader): string |
|
|||||||
if (!cookieHeader) {
|
if (!cookieHeader) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
let sessionToken: string | null = null;
|
||||||
for (const part of cookieHeader.split(';')) {
|
for (const part of cookieHeader.split(';')) {
|
||||||
const [name, ...value] = part.trim().split('=');
|
const [name, ...value] = part.trim().split('=');
|
||||||
if (name === 'mpm_session') {
|
if (name === 'mpm_session') {
|
||||||
return decodeURIComponent(value.join('='));
|
// Browsers may send same-name cookies from an older, narrower Path
|
||||||
|
// before the current Path=/ cookie. The last value is the root cookie.
|
||||||
|
sessionToken = decodeURIComponent(value.join('='));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return sessionToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface RateLimitEntry {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class RateLimiterService {
|
export class RateLimiterService {
|
||||||
private readonly entries = new Map<string, RateLimitEntry>();
|
private readonly entries = new Map<string, RateLimitEntry>();
|
||||||
|
private readonly maxEntries = 10_000;
|
||||||
|
private readonly cleanupIntervalMs = 60_000;
|
||||||
|
private lastCleanupAt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft, ob ein Request innerhalb des Limits liegt.
|
* Prüft, ob ein Request innerhalb des Limits liegt.
|
||||||
@@ -21,6 +24,17 @@ export class RateLimiterService {
|
|||||||
isAllowed(key: string, limit: number, windowMinutes: number): boolean {
|
isAllowed(key: string, limit: number, windowMinutes: number): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = windowMinutes * 60_000;
|
const windowMs = windowMinutes * 60_000;
|
||||||
|
if (now - this.lastCleanupAt >= this.cleanupIntervalMs) {
|
||||||
|
for (const [entryKey, entry] of this.entries) {
|
||||||
|
const recentTimestamps = entry.timestamps.filter((timestamp) => now - timestamp < windowMs);
|
||||||
|
if (recentTimestamps.length === 0) {
|
||||||
|
this.entries.delete(entryKey);
|
||||||
|
} else if (recentTimestamps.length !== entry.timestamps.length) {
|
||||||
|
this.entries.set(entryKey, { timestamps: recentTimestamps });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lastCleanupAt = now;
|
||||||
|
}
|
||||||
const entry = this.entries.get(key) ?? { timestamps: [] };
|
const entry = this.entries.get(key) ?? { timestamps: [] };
|
||||||
const recent = entry.timestamps.filter((timestamp) => now - timestamp < windowMs);
|
const recent = entry.timestamps.filter((timestamp) => now - timestamp < windowMs);
|
||||||
|
|
||||||
@@ -30,6 +44,10 @@ export class RateLimiterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recent.push(now);
|
recent.push(now);
|
||||||
|
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
|
||||||
|
const oldestKey = this.entries.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) this.entries.delete(oldestKey);
|
||||||
|
}
|
||||||
this.entries.set(key, { timestamps: recent });
|
this.entries.set(key, { timestamps: recent });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export interface SecurityConfig {
|
|||||||
readonly sessionTtlMinutes: number;
|
readonly sessionTtlMinutes: number;
|
||||||
readonly cookieSecure: boolean;
|
readonly cookieSecure: boolean;
|
||||||
readonly behindProxy: boolean;
|
readonly behindProxy: boolean;
|
||||||
|
/** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */
|
||||||
readonly loginMaxAttempts: number;
|
readonly loginMaxAttempts: number;
|
||||||
|
/** @deprecated Account lockout was removed to prevent attacker-triggered account denial. */
|
||||||
readonly loginLockoutMinutes: number;
|
readonly loginLockoutMinutes: number;
|
||||||
readonly loginRateLimitAttempts: number;
|
readonly loginRateLimitAttempts: number;
|
||||||
readonly loginRateLimitWindowMinutes: number;
|
readonly loginRateLimitWindowMinutes: number;
|
||||||
@@ -32,6 +34,23 @@ export interface AdminSeedConfig {
|
|||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
readonly modulesDir: string;
|
readonly modulesDir: string;
|
||||||
readonly logsDir: string;
|
readonly logsDir: string;
|
||||||
|
readonly moduleUidBase?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceProviderConfig {
|
||||||
|
readonly clientId: string;
|
||||||
|
readonly clientSecret: string;
|
||||||
|
readonly baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceConfig {
|
||||||
|
readonly publicUrl: string;
|
||||||
|
readonly tokenEncryptionKey: string;
|
||||||
|
readonly providers: {
|
||||||
|
readonly github?: MarketplaceProviderConfig;
|
||||||
|
readonly gitea?: MarketplaceProviderConfig;
|
||||||
|
readonly forgejo?: MarketplaceProviderConfig;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -41,6 +60,7 @@ export interface AppConfig {
|
|||||||
readonly security: SecurityConfig;
|
readonly security: SecurityConfig;
|
||||||
readonly adminSeed: AdminSeedConfig;
|
readonly adminSeed: AdminSeedConfig;
|
||||||
readonly runtime: RuntimeConfig;
|
readonly runtime: RuntimeConfig;
|
||||||
|
readonly marketplace: MarketplaceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const booleanFromString = z
|
const booleanFromString = z
|
||||||
@@ -63,7 +83,80 @@ const environmentSchema = z.object({
|
|||||||
ADMIN_EMAIL: z.string().trim().email(),
|
ADMIN_EMAIL: z.string().trim().email(),
|
||||||
ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200),
|
ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200),
|
||||||
MODULES_DIR: z.string().min(1).default('./data/modules'),
|
MODULES_DIR: z.string().min(1).default('./data/modules'),
|
||||||
|
MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'),
|
||||||
LOGS_DIR: z.string().min(1).default('./data/logs'),
|
LOGS_DIR: z.string().min(1).default('./data/logs'),
|
||||||
|
MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(),
|
||||||
|
MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'),
|
||||||
|
MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''),
|
||||||
|
GITHUB_OAUTH_CLIENT_ID: z.string().default(''),
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET: z.string().default(''),
|
||||||
|
GITEA_BASE_URL: z.string().default(''),
|
||||||
|
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
|
||||||
|
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
|
||||||
|
FORGEJO_BASE_URL: z.string().default(''),
|
||||||
|
FORGEJO_OAUTH_CLIENT_ID: z.string().default(''),
|
||||||
|
FORGEJO_OAUTH_CLIENT_SECRET: z.string().default(''),
|
||||||
|
}).superRefine((environment, context) => {
|
||||||
|
if (environment.NODE_ENV === 'production' && !environment.COOKIE_SECURE) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['COOKIE_SECURE'],
|
||||||
|
message: 'COOKIE_SECURE muss in production auf true gesetzt sein',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (environment.NODE_ENV === 'production' && environment.MODULE_UID_BASE === undefined) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['MODULE_UID_BASE'],
|
||||||
|
message: 'MODULE_UID_BASE ist in production erforderlich, damit Module getrennte UIDs erhalten',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const oauthFields = [
|
||||||
|
environment.GITHUB_OAUTH_CLIENT_ID,
|
||||||
|
environment.GITHUB_OAUTH_CLIENT_SECRET,
|
||||||
|
environment.GITEA_BASE_URL,
|
||||||
|
environment.GITEA_OAUTH_CLIENT_ID,
|
||||||
|
environment.GITEA_OAUTH_CLIENT_SECRET,
|
||||||
|
environment.FORGEJO_BASE_URL,
|
||||||
|
environment.FORGEJO_OAUTH_CLIENT_ID,
|
||||||
|
environment.FORGEJO_OAUTH_CLIENT_SECRET,
|
||||||
|
];
|
||||||
|
if (oauthFields.some(Boolean) && environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY.length < 32) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['MARKETPLACE_TOKEN_ENCRYPTION_KEY'],
|
||||||
|
message: 'Bei aktivierten OAuth-Anbietern ist ein Schlüssel mit mindestens 32 Zeichen erforderlich',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [provider, fields] of [
|
||||||
|
['GITHUB', [environment.GITHUB_OAUTH_CLIENT_ID, environment.GITHUB_OAUTH_CLIENT_SECRET]],
|
||||||
|
['GITEA', [environment.GITEA_BASE_URL, environment.GITEA_OAUTH_CLIENT_ID, environment.GITEA_OAUTH_CLIENT_SECRET]],
|
||||||
|
['FORGEJO', [environment.FORGEJO_BASE_URL, environment.FORGEJO_OAUTH_CLIENT_ID, environment.FORGEJO_OAUTH_CLIENT_SECRET]],
|
||||||
|
] as const) {
|
||||||
|
if (fields.some(Boolean) && fields.some((field) => !field)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [`${provider}_OAUTH_CLIENT_ID`],
|
||||||
|
message: `OAuth-Konfiguration für ${provider} ist unvollständig`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [field, value] of [['GITEA_BASE_URL', environment.GITEA_BASE_URL], ['FORGEJO_BASE_URL', environment.FORGEJO_BASE_URL]] as const) {
|
||||||
|
if (!value) continue;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
||||||
|
throw new Error('protocol');
|
||||||
|
}
|
||||||
|
if (url.username || url.password || url.search || url.hash) throw new Error('url');
|
||||||
|
} catch {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [field],
|
||||||
|
message: 'Forge-URL muss HTTPS verwenden (HTTP ist nur lokal zulässig) und darf keine Zugangsdaten enthalten',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */
|
/** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */
|
||||||
@@ -91,6 +184,24 @@ export function loadConfiguration(): AppConfig {
|
|||||||
runtime: {
|
runtime: {
|
||||||
modulesDir: path.resolve(environment.MODULES_DIR),
|
modulesDir: path.resolve(environment.MODULES_DIR),
|
||||||
logsDir: path.resolve(environment.LOGS_DIR),
|
logsDir: path.resolve(environment.LOGS_DIR),
|
||||||
|
...(environment.MODULE_UID_BASE !== undefined
|
||||||
|
? { moduleUidBase: environment.MODULE_UID_BASE }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
marketplace: {
|
||||||
|
publicUrl: environment.MARKETPLACE_PUBLIC_URL.replace(/\/$/, ''),
|
||||||
|
tokenEncryptionKey: environment.MARKETPLACE_TOKEN_ENCRYPTION_KEY,
|
||||||
|
providers: {
|
||||||
|
...(environment.GITHUB_OAUTH_CLIENT_ID && environment.GITHUB_OAUTH_CLIENT_SECRET
|
||||||
|
? { github: { clientId: environment.GITHUB_OAUTH_CLIENT_ID, clientSecret: environment.GITHUB_OAUTH_CLIENT_SECRET, baseUrl: 'https://github.com' } }
|
||||||
|
: {}),
|
||||||
|
...(environment.GITEA_BASE_URL && environment.GITEA_OAUTH_CLIENT_ID && environment.GITEA_OAUTH_CLIENT_SECRET
|
||||||
|
? { gitea: { clientId: environment.GITEA_OAUTH_CLIENT_ID, clientSecret: environment.GITEA_OAUTH_CLIENT_SECRET, baseUrl: environment.GITEA_BASE_URL.replace(/\/$/, '') } }
|
||||||
|
: {}),
|
||||||
|
...(environment.FORGEJO_BASE_URL && environment.FORGEJO_OAUTH_CLIENT_ID && environment.FORGEJO_OAUTH_CLIENT_SECRET
|
||||||
|
? { forgejo: { clientId: environment.FORGEJO_OAUTH_CLIENT_ID, clientSecret: environment.FORGEJO_OAUTH_CLIENT_SECRET, baseUrl: environment.FORGEJO_BASE_URL.replace(/\/$/, '') } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,12 @@ import { migration001CoreSchema } from './001-core-schema';
|
|||||||
import { migration002Modules } from '../../modules/migrations/002-modules';
|
import { migration002Modules } from '../../modules/migrations/002-modules';
|
||||||
import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions';
|
import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions';
|
||||||
import { migration004SystemSettings } from '../../settings/migrations/004-system-settings';
|
import { migration004SystemSettings } from '../../settings/migrations/004-system-settings';
|
||||||
|
import { migration005ModulePortUnique } from '../../modules/migrations/005-module-port-unique';
|
||||||
|
import { migration006MarketplaceConnections } from '../../modules/migrations/006-marketplace-connections';
|
||||||
|
import { migration007MarketplaceCatalog } from '../../modules/migrations/007-marketplace-catalog';
|
||||||
|
import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch';
|
||||||
|
import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations';
|
||||||
|
import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers';
|
||||||
|
|
||||||
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
|
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
|
||||||
export const MIGRATIONS = [
|
export const MIGRATIONS = [
|
||||||
@@ -9,4 +15,10 @@ export const MIGRATIONS = [
|
|||||||
migration002Modules,
|
migration002Modules,
|
||||||
migration003ModulePermissions,
|
migration003ModulePermissions,
|
||||||
migration004SystemSettings,
|
migration004SystemSettings,
|
||||||
|
migration005ModulePortUnique,
|
||||||
|
migration006MarketplaceConnections,
|
||||||
|
migration007MarketplaceCatalog,
|
||||||
|
migration008MarketplaceSourceBranch,
|
||||||
|
migration009MarketplaceInstallations,
|
||||||
|
migration010ModuleContainers,
|
||||||
];
|
];
|
||||||
@@ -4,6 +4,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
import { loadConfiguration } from './config/config.tokens';
|
import { loadConfiguration } from './config/config.tokens';
|
||||||
@@ -26,6 +27,10 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
app.use('/api', (_request: Request, response: Response, next: NextFunction) => {
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
if (config.security.behindProxy) {
|
if (config.security.behindProxy) {
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const moduleManifestSchema = z.object({
|
|||||||
.max(MODULE_PORT_MAX, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`),
|
.max(MODULE_PORT_MAX, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`),
|
||||||
healthcheck: z.string().regex(/^\/[A-Za-z0-9\-./]*$/, 'Healthcheck muss ein Pfad sein'),
|
healthcheck: z.string().regex(/^\/[A-Za-z0-9\-./]*$/, 'Healthcheck muss ein Pfad sein'),
|
||||||
apiVersion: z.literal('v1'),
|
apiVersion: z.literal('v1'),
|
||||||
|
composeFile: z.string().min(1).max(200).optional(),
|
||||||
|
appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(),
|
||||||
});
|
});
|
||||||
export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
|
||||||
|
|
||||||
@@ -71,4 +73,6 @@ export interface ModuleRecord {
|
|||||||
readonly enabled: boolean;
|
readonly enabled: boolean;
|
||||||
readonly createdAt: Date;
|
readonly createdAt: Date;
|
||||||
readonly updatedAt: Date;
|
readonly updatedAt: Date;
|
||||||
|
readonly composeFile?: string | null;
|
||||||
|
readonly appService?: string | null;
|
||||||
}
|
}
|
||||||
123
apps/platform-backend/src/modules/marketplace.controller.ts
Normal file
123
apps/platform-backend/src/modules/marketplace.controller.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import type { AuthUser } from '../users/user.types';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { MarketplaceService } from './marketplace.service';
|
||||||
|
import { ModulesService } from './modules.service';
|
||||||
|
|
||||||
|
@ApiTags('Marketplace')
|
||||||
|
@Controller({ path: 'api/v1/marketplace' })
|
||||||
|
export class MarketplaceController {
|
||||||
|
constructor(
|
||||||
|
private readonly marketplaceService: MarketplaceService,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
|
private readonly modulesService: ModulesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('providers')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
providers(): Promise<Awaited<ReturnType<MarketplaceService['providers']>>> {
|
||||||
|
return this.marketplaceService.providers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('repositories/:provider')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
repositories(@Param('provider') provider: string): ReturnType<MarketplaceService['repositories']> {
|
||||||
|
return this.marketplaceService.repositories(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('repositories/:provider/:owner/:repository/install')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
async installRepository(
|
||||||
|
@Param('provider') provider: string,
|
||||||
|
@Param('owner') owner: string,
|
||||||
|
@Param('repository') repository: string,
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
): Promise<{ module: {
|
||||||
|
id: string; moduleId: string; name: string; slug: string; version: string; description: string;
|
||||||
|
author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string;
|
||||||
|
} }> {
|
||||||
|
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository);
|
||||||
|
const manifest = await this.modulesService.validatePackage(archive);
|
||||||
|
const module = await this.modulesService.findByModuleId(manifest.id) ??
|
||||||
|
await this.modulesService.install(archive, actor, request.ip ?? null);
|
||||||
|
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id);
|
||||||
|
return {
|
||||||
|
module: {
|
||||||
|
id: module.id,
|
||||||
|
moduleId: module.moduleId,
|
||||||
|
name: module.name,
|
||||||
|
slug: module.slug,
|
||||||
|
version: module.version,
|
||||||
|
description: module.description,
|
||||||
|
author: module.author,
|
||||||
|
status: module.status,
|
||||||
|
internalPort: module.internalPort,
|
||||||
|
healthcheckUrl: module.healthcheckUrl,
|
||||||
|
enabled: module.enabled,
|
||||||
|
createdAt: module.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('connections/:provider/start')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
async startConnection(
|
||||||
|
@Param('provider') provider: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
): Promise<{ authorizationUrl: string }> {
|
||||||
|
if (!request.session?.id) throw new BadRequestException('Session konnte nicht geprüft werden');
|
||||||
|
return { authorizationUrl: await this.marketplaceService.beginConnection(provider, user.id, request.session.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('connections/:provider')
|
||||||
|
@Roles('ADMIN')
|
||||||
|
async disconnect(@Param('provider') provider: string): Promise<{ success: true }> {
|
||||||
|
await this.marketplaceService.disconnect(provider);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('oauth/:provider/callback')
|
||||||
|
@Public()
|
||||||
|
async callback(
|
||||||
|
@Param('provider') provider: string,
|
||||||
|
@Query('code') code: string | undefined,
|
||||||
|
@Query('state') state: string | undefined,
|
||||||
|
@Query('error') error: string | undefined,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const destination = new URL('/admin/modules', this.marketplaceService.frontendUrl());
|
||||||
|
if (error) {
|
||||||
|
destination.searchParams.set('marketplace', 'denied');
|
||||||
|
} else if (!code || !state) {
|
||||||
|
destination.searchParams.set('marketplace', 'error');
|
||||||
|
destination.searchParams.set('reason', 'callback');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const token = request.cookies?.mpm_session as string | undefined;
|
||||||
|
const session = token ? await this.sessionService.findValid(token) : null;
|
||||||
|
if (!session) {
|
||||||
|
destination.searchParams.set('marketplace', 'error');
|
||||||
|
destination.searchParams.set('reason', 'session');
|
||||||
|
response.redirect(302, destination.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.marketplaceService.completeConnection(provider, code, state, session?.id ?? null);
|
||||||
|
destination.searchParams.set('marketplace', 'connected');
|
||||||
|
destination.searchParams.set('provider', provider);
|
||||||
|
} catch {
|
||||||
|
destination.searchParams.set('marketplace', 'error');
|
||||||
|
destination.searchParams.set('reason', 'oauth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.redirect(302, destination.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
411
apps/platform-backend/src/modules/marketplace.service.ts
Normal file
411
apps/platform-backend/src/modules/marketplace.service.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
|
||||||
|
export const MARKETPLACE_PROVIDERS = ['github', 'gitea', 'forgejo'] as const;
|
||||||
|
export type MarketplaceProvider = (typeof MARKETPLACE_PROVIDERS)[number];
|
||||||
|
|
||||||
|
interface ProviderStatus {
|
||||||
|
provider: MarketplaceProvider;
|
||||||
|
label: string;
|
||||||
|
configured: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
accountLogin: string | null;
|
||||||
|
baseUrl: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthStateRow {
|
||||||
|
readonly provider: MarketplaceProvider;
|
||||||
|
readonly user_id: string;
|
||||||
|
readonly session_id: string;
|
||||||
|
readonly code_verifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderIdentity {
|
||||||
|
readonly id: string | number;
|
||||||
|
readonly login?: string;
|
||||||
|
readonly username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isProvider(value: string): value is MarketplaceProvider {
|
||||||
|
return MARKETPLACE_PROVIDERS.includes(value as MarketplaceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBaseUrl(value: string): string {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname))) {
|
||||||
|
throw new BadRequestException('Forge-URL muss HTTPS verwenden (HTTP ist nur für lokale Entwicklung erlaubt)');
|
||||||
|
}
|
||||||
|
if (url.username || url.password || url.search || url.hash) {
|
||||||
|
throw new BadRequestException('Forge-URL darf keine Zugangsdaten oder URL-Parameter enthalten');
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketplaceService {
|
||||||
|
constructor(
|
||||||
|
private readonly database: DatabaseService,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async providers(): Promise<ProviderStatus[]> {
|
||||||
|
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
|
||||||
|
'SELECT provider, account_login FROM marketplace_connections',
|
||||||
|
);
|
||||||
|
const connectedByProvider = new Map(connected.rows.map((row) => [row.provider, row.account_login]));
|
||||||
|
const labels: Record<MarketplaceProvider, string> = {
|
||||||
|
github: 'GitHub', gitea: 'Gitea', forgejo: 'Forgejo',
|
||||||
|
};
|
||||||
|
return MARKETPLACE_PROVIDERS.map((provider) => {
|
||||||
|
const configured = this.config.marketplace.providers[provider];
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
label: labels[provider],
|
||||||
|
configured: Boolean(configured),
|
||||||
|
connected: connectedByProvider.has(provider),
|
||||||
|
accountLogin: connectedByProvider.get(provider) ?? null,
|
||||||
|
baseUrl: configured?.baseUrl ?? (provider === 'github' ? 'https://github.com' : ''),
|
||||||
|
callbackUrl: this.callbackUrl(provider),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async beginConnection(providerParam: string, userId: string, sessionId: string): Promise<string> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
const providerConfig = this.config.marketplace.providers[provider];
|
||||||
|
if (!providerConfig) {
|
||||||
|
throw new BadRequestException(`${provider} ist noch nicht konfiguriert`);
|
||||||
|
}
|
||||||
|
if (this.config.marketplace.tokenEncryptionKey.length < 32) {
|
||||||
|
throw new InternalServerErrorException('MARKETPLACE_TOKEN_ENCRYPTION_KEY muss mindestens 32 Zeichen lang sein');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = randomBytes(32).toString('base64url');
|
||||||
|
const verifier = randomBytes(48).toString('base64url');
|
||||||
|
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
const callback = this.callbackUrl(provider);
|
||||||
|
await this.database.query(
|
||||||
|
`INSERT INTO marketplace_oauth_states(state_hash, provider, user_id, session_id, code_verifier, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')`,
|
||||||
|
[this.hash(state), provider, userId, sessionId, verifier],
|
||||||
|
);
|
||||||
|
await this.database.query('DELETE FROM marketplace_oauth_states WHERE expires_at < now()');
|
||||||
|
|
||||||
|
const authorizeUrl = new URL(
|
||||||
|
provider === 'github'
|
||||||
|
? '/login/oauth/authorize'
|
||||||
|
: `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/login/oauth/authorize`,
|
||||||
|
providerConfig.baseUrl,
|
||||||
|
);
|
||||||
|
authorizeUrl.searchParams.set('client_id', providerConfig.clientId);
|
||||||
|
authorizeUrl.searchParams.set('redirect_uri', callback);
|
||||||
|
authorizeUrl.searchParams.set('response_type', 'code');
|
||||||
|
authorizeUrl.searchParams.set('state', state);
|
||||||
|
authorizeUrl.searchParams.set('code_challenge', challenge);
|
||||||
|
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
authorizeUrl.searchParams.set('scope', 'read:user');
|
||||||
|
return authorizeUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeConnection(providerParam: string, code: string, state: string, sessionId: string | null): Promise<void> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
if (!code || code.length > 4096 || !state || state.length > 256 || !sessionId) {
|
||||||
|
throw new BadRequestException('OAuth-Rückgabe ist ungültig');
|
||||||
|
}
|
||||||
|
const result = await this.database.query<OAuthStateRow>(
|
||||||
|
`DELETE FROM marketplace_oauth_states
|
||||||
|
WHERE state_hash = $1 AND provider = $2 AND session_id = $3 AND expires_at > now()
|
||||||
|
RETURNING provider, user_id, session_id, code_verifier`,
|
||||||
|
[this.hash(state), provider, sessionId],
|
||||||
|
);
|
||||||
|
const oauthState = result.rows[0];
|
||||||
|
if (!oauthState) {
|
||||||
|
throw new UnauthorizedException('OAuth-Status ist ungültig oder abgelaufen. Bitte erneut verbinden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = this.config.marketplace.providers[provider];
|
||||||
|
if (!providerConfig) throw new BadRequestException(`${provider} ist nicht konfiguriert`);
|
||||||
|
const callback = this.callbackUrl(provider);
|
||||||
|
const token = await this.exchangeCode(provider, providerConfig, code, callback, oauthState.code_verifier);
|
||||||
|
const identity = await this.fetchIdentity(provider, providerConfig.baseUrl, token);
|
||||||
|
const login = identity.login ?? identity.username;
|
||||||
|
if (!login || identity.id === undefined || identity.id === null) {
|
||||||
|
throw new BadGatewayException('Der Forge hat keine gültige Benutzeridentität zurückgegeben');
|
||||||
|
}
|
||||||
|
const encrypted = this.encryptToken(token);
|
||||||
|
await this.database.query(
|
||||||
|
`INSERT INTO marketplace_connections
|
||||||
|
(provider, account_id, account_login, token_ciphertext, token_iv, token_tag, connected_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (provider) DO UPDATE SET
|
||||||
|
account_id = EXCLUDED.account_id,
|
||||||
|
account_login = EXCLUDED.account_login,
|
||||||
|
token_ciphertext = EXCLUDED.token_ciphertext,
|
||||||
|
token_iv = EXCLUDED.token_iv,
|
||||||
|
token_tag = EXCLUDED.token_tag,
|
||||||
|
connected_by = EXCLUDED.connected_by,
|
||||||
|
connected_at = now()`,
|
||||||
|
[provider, String(identity.id), login, encrypted.ciphertext, encrypted.iv, encrypted.tag, oauthState.user_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(providerParam: string): Promise<void> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
await this.database.query('DELETE FROM marketplace_connections WHERE provider = $1', [provider]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async repositories(providerParam: string): Promise<Array<{ owner: string; repository: string; htmlUrl: string; description: string; defaultBranch: string; installed: boolean }>> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
const providerConfig = this.config.marketplace.providers[provider];
|
||||||
|
if (!providerConfig) throw new BadRequestException(`${provider} ist nicht konfiguriert`);
|
||||||
|
const connection = await this.database.query<{ account_login: string }>(
|
||||||
|
'SELECT account_login FROM marketplace_connections WHERE provider = $1', [provider],
|
||||||
|
);
|
||||||
|
const login = connection.rows[0]?.account_login;
|
||||||
|
if (!login) throw new BadRequestException(`${provider} ist nicht verbunden`);
|
||||||
|
const path = provider === 'github'
|
||||||
|
? `/users/${encodeURIComponent(login)}/repos?type=owner&sort=updated&per_page=50`
|
||||||
|
: `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(login)}/repos?limit=50&sort=updated`;
|
||||||
|
const response = await this.forgeJson<Array<Record<string, unknown>>>(provider, providerConfig.baseUrl, null, path);
|
||||||
|
const repositories = response.filter((repo) => repo.private !== true).map((repo) => {
|
||||||
|
const owner = typeof repo.owner === 'object' && repo.owner !== null
|
||||||
|
? String((repo.owner as Record<string, unknown>).login ?? (repo.owner as Record<string, unknown>).username ?? login)
|
||||||
|
: login;
|
||||||
|
const repository = String(repo.name ?? '');
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
htmlUrl: String(repo.html_url ?? ''),
|
||||||
|
description: String(repo.description ?? ''),
|
||||||
|
defaultBranch: String(repo.default_branch ?? 'main'),
|
||||||
|
};
|
||||||
|
}).filter((repo) => repo.repository && repo.htmlUrl);
|
||||||
|
const installed = await this.database.query<{ owner: string; repository: string }>(
|
||||||
|
'SELECT owner, repository FROM marketplace_module_installations WHERE provider = $1',
|
||||||
|
[provider],
|
||||||
|
);
|
||||||
|
const installedRepositories = new Set(installed.rows.map((row) => `${row.owner.toLowerCase()}/${row.repository.toLowerCase()}`));
|
||||||
|
return repositories.map((repo) => ({
|
||||||
|
...repo,
|
||||||
|
installed: installedRepositories.has(`${repo.owner.toLowerCase()}/${repo.repository.toLowerCase()}`),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string): Promise<void> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
await this.database.query(
|
||||||
|
`INSERT INTO marketplace_module_installations (provider, owner, repository, module_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`,
|
||||||
|
[provider, owner, repository, moduleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadRepositoryArchive(providerParam: string, owner: string, repository: string): Promise<Buffer> {
|
||||||
|
const provider = this.requireProvider(providerParam);
|
||||||
|
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
|
||||||
|
throw new BadRequestException('Repository-Angabe ist ungueltig');
|
||||||
|
}
|
||||||
|
const providerConfig = this.config.marketplace.providers[provider];
|
||||||
|
if (!providerConfig) throw new BadRequestException('Forge-Anbieter ist nicht konfiguriert');
|
||||||
|
const repo = await this.forgeJson<Record<string, unknown>>(
|
||||||
|
provider,
|
||||||
|
providerConfig.baseUrl,
|
||||||
|
null,
|
||||||
|
this.repositoryApiPath(provider, owner, repository),
|
||||||
|
);
|
||||||
|
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
|
||||||
|
const defaultBranch = String(repo.default_branch ?? 'main');
|
||||||
|
if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig');
|
||||||
|
const archivePath = provider === 'github'
|
||||||
|
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch)
|
||||||
|
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip';
|
||||||
|
const archiveUrl = provider === 'github'
|
||||||
|
? new URL(archivePath, 'https://api.github.com').toString()
|
||||||
|
: new URL(archivePath, providerConfig.baseUrl).toString();
|
||||||
|
const allowedHosts = this.downloadHosts(providerConfig.baseUrl, provider);
|
||||||
|
const headers: Record<string, string> = provider === 'github' ? { 'User-Agent': 'MPM-Module-Marketplace' } : {};
|
||||||
|
const archive = await this.downloadBounded(archiveUrl, headers, allowedHosts, MAX_MARKETPLACE_DOWNLOAD);
|
||||||
|
return this.normalizeRepositoryArchive(archive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> {
|
||||||
|
const AdmZip = (await import('adm-zip')).default;
|
||||||
|
const input = new AdmZip(archive);
|
||||||
|
const entries = input.getEntries();
|
||||||
|
const files = entries.filter((entry) => !entry.isDirectory);
|
||||||
|
if (!files.length || files.length > 2000) throw new BadRequestException('Repository-Archiv enthaelt keine gueltigen Moduldateien');
|
||||||
|
const firstPath = files[0].entryName;
|
||||||
|
const firstSlash = firstPath.indexOf('/');
|
||||||
|
const candidateRoot = firstSlash > 0 ? firstPath.slice(0, firstSlash) : '';
|
||||||
|
const hasRootManifest = files.some((entry) => entry.entryName === 'module.json');
|
||||||
|
const root = hasRootManifest ? '' : candidateRoot;
|
||||||
|
if (!root && !hasRootManifest) throw new BadRequestException('Repository-Archiv muss module.json im Stammverzeichnis enthalten');
|
||||||
|
let totalUncompressed = 0;
|
||||||
|
for (const entry of files) {
|
||||||
|
const name = entry.entryName;
|
||||||
|
if (name.includes(String.fromCharCode(92)) || name.startsWith('/') || name.split('/').includes('..')) throw new BadRequestException('Unsicherer Pfad im Repository-Archiv');
|
||||||
|
if (root && !name.startsWith(root + '/')) throw new BadRequestException('Repository-Archiv hat mehrere Stammverzeichnisse');
|
||||||
|
const unixType = (entry.header.attr >>> 16) & 0xf000;
|
||||||
|
if (unixType === 0xa000) throw new BadRequestException('Symlinks sind in Marketplace-Modulen nicht erlaubt');
|
||||||
|
totalUncompressed += entry.header.size;
|
||||||
|
if (totalUncompressed > 50 * 1024 * 1024) throw new BadRequestException('Repository-Archiv ist entpackt zu gross');
|
||||||
|
}
|
||||||
|
const output = new AdmZip();
|
||||||
|
for (const entry of files) {
|
||||||
|
const relative = root ? entry.entryName.slice(root.length + 1) : entry.entryName;
|
||||||
|
if (relative) output.addFile(relative, entry.getData());
|
||||||
|
}
|
||||||
|
const normalized = output.toBuffer();
|
||||||
|
if (normalized.length > MAX_MARKETPLACE_DOWNLOAD) throw new BadRequestException('Modul-Paket ueberschreitet 10 MB');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private repositoryApiPath(provider: MarketplaceProvider, owner: string, repository: string): string {
|
||||||
|
return provider === 'github'
|
||||||
|
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`
|
||||||
|
: `${new URL(this.config.marketplace.providers[provider]!.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async forgeJson<T>(provider: MarketplaceProvider, baseUrl: string, token: string | null, path: string): Promise<T> {
|
||||||
|
const url = provider === 'github' ? new URL(path, 'https://api.github.com') : new URL(path, baseUrl);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json', ...this.providerHeaders(provider, token) },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
}).catch(() => { throw new BadGatewayException('Forge-Katalog konnte nicht geladen werden'); });
|
||||||
|
if (!response.ok) throw new BadGatewayException('Forge-Katalog konnte nicht geladen werden');
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private providerHeaders(provider: MarketplaceProvider, token: string | null): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(provider === 'github' ? { 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'MPM-Module-Marketplace' } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private downloadHosts(baseUrl: string, provider: MarketplaceProvider): string[] {
|
||||||
|
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||||
|
return provider === 'github' ? [host, 'api.github.com', 'codeload.github.com'] : [host];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadBounded(urlValue: string, headers: Record<string, string>, allowedHosts: string[], maxBytes: number): Promise<Buffer> {
|
||||||
|
let url = new URL(urlValue);
|
||||||
|
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
||||||
|
if (url.protocol !== 'https:' || !allowedHosts.includes(url.hostname.toLowerCase())) throw new BadRequestException('Forge hat eine nicht vertrauenswürdige Download-Adresse geliefert');
|
||||||
|
const response = await fetch(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(30_000) });
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location || redirects === 3) throw new BadGatewayException('Forge-Download konnte nicht aufgelöst werden');
|
||||||
|
url = new URL(location, url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!response.ok || !response.body) throw new BadGatewayException('Forge-Download ist fehlgeschlagen');
|
||||||
|
const size = Number(response.headers.get('content-length') ?? 0);
|
||||||
|
if (size > maxBytes) throw new BadRequestException('Forge-Paket überschreitet die erlaubte Größe');
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
total += chunk.value.byteLength;
|
||||||
|
if (total > maxBytes) { await reader.cancel(); throw new BadRequestException('Forge-Download überschreitet die erlaubte Größe'); }
|
||||||
|
chunks.push(Buffer.from(chunk.value));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks, total);
|
||||||
|
}
|
||||||
|
throw new BadGatewayException('Forge-Download konnte nicht aufgelöst werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackUrl(provider: MarketplaceProvider): string {
|
||||||
|
return `${this.config.marketplace.publicUrl}/api/v1/marketplace/oauth/${provider}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
frontendUrl(): string {
|
||||||
|
return this.config.marketplace.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireProvider(provider: string): MarketplaceProvider {
|
||||||
|
if (!isProvider(provider)) throw new NotFoundException('Unbekannter Forge-Anbieter');
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hash(value: string | Buffer): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptToken(token: string): { ciphertext: string; iv: string; tag: string } {
|
||||||
|
const key = createHash('sha256').update(this.config.marketplace.tokenEncryptionKey).digest();
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
||||||
|
return {
|
||||||
|
ciphertext: ciphertext.toString('base64'),
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
tag: cipher.getAuthTag().toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCode(
|
||||||
|
provider: MarketplaceProvider,
|
||||||
|
config: NonNullable<AppConfig['marketplace']['providers'][MarketplaceProvider]>,
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
verifier: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const tokenUrl = new URL(
|
||||||
|
provider === 'github'
|
||||||
|
? '/login/oauth/access_token'
|
||||||
|
: `${new URL(config.baseUrl).pathname.replace(/\/$/, '')}/login/oauth/access_token`,
|
||||||
|
config.baseUrl,
|
||||||
|
);
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
}).catch(() => { throw new BadGatewayException('Token-Austausch beim Forge ist fehlgeschlagen'); });
|
||||||
|
const body = await response.json().catch(() => null) as { access_token?: unknown; error?: unknown } | null;
|
||||||
|
if (!response.ok || !body || typeof body.access_token !== 'string') {
|
||||||
|
throw new BadGatewayException('Forge hat die OAuth-Autorisierung abgelehnt');
|
||||||
|
}
|
||||||
|
return body.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchIdentity(provider: MarketplaceProvider, baseUrl: string, token: string): Promise<ProviderIdentity> {
|
||||||
|
const userUrl = provider === 'github'
|
||||||
|
? 'https://api.github.com/user'
|
||||||
|
: `${safeBaseUrl(baseUrl)}/api/v1/user`;
|
||||||
|
const response = await fetch(userUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(provider === 'github' ? { 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'MPM-Module-Marketplace' } : {}),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
}).catch(() => { throw new BadGatewayException('Benutzerkonto beim Forge konnte nicht gelesen werden'); });
|
||||||
|
if (!response.ok) throw new BadGatewayException('Benutzerkonto beim Forge konnte nicht gelesen werden');
|
||||||
|
return response.json() as Promise<ProviderIdentity>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
/** Internal ports map to isolated module UIDs and must be unique. */
|
||||||
|
export const migration005ModulePortUnique: Migration = {
|
||||||
|
id: '005-module-port-unique',
|
||||||
|
description: 'Eindeutige interne Modul-Ports sicherstellen',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_modules_internal_port_unique ON modules(internal_port)',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
export const migration006MarketplaceConnections: Migration = {
|
||||||
|
id: '006-marketplace-connections',
|
||||||
|
description: 'OAuth-Verbindungen für Modulquellen speichern',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE marketplace_oauth_states (
|
||||||
|
state_hash TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
code_verifier TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX idx_marketplace_oauth_states_expiry
|
||||||
|
ON marketplace_oauth_states(expires_at)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE marketplace_connections (
|
||||||
|
provider TEXT PRIMARY KEY CHECK (provider IN ('github', 'gitea', 'forgejo')),
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
account_login TEXT NOT NULL,
|
||||||
|
token_ciphertext TEXT NOT NULL,
|
||||||
|
token_iv TEXT NOT NULL,
|
||||||
|
token_tag TEXT NOT NULL,
|
||||||
|
connected_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
connected_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
export const migration007MarketplaceCatalog: Migration = {
|
||||||
|
id: '007-marketplace-catalog',
|
||||||
|
description: 'Ausgewählte Repository-Quellen für den Modul-Marktplatz',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE marketplace_catalog_sources (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')),
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
html_url TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
added_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, owner, repository)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
export const migration008MarketplaceSourceBranch: Migration = {
|
||||||
|
id: '008-marketplace-source-branch',
|
||||||
|
description: 'Standard-Branch für direkt installierbare Marketplace-Quellen speichern',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE marketplace_catalog_sources
|
||||||
|
ADD COLUMN default_branch TEXT NOT NULL DEFAULT 'main'
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
export const migration009MarketplaceInstallations: Migration = {
|
||||||
|
id: '009-marketplace-installations',
|
||||||
|
description: 'Marketplace-Repositories installierten Modulen zuordnen',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE marketplace_module_installations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('github', 'gitea', 'forgejo')),
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
repository TEXT NOT NULL,
|
||||||
|
module_id UUID NOT NULL UNIQUE REFERENCES modules(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, owner, repository)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Migration } from '../../database/migration.types';
|
||||||
|
|
||||||
|
export const migration010ModuleContainers: Migration = {
|
||||||
|
id: '010-module-containers',
|
||||||
|
description: 'Container-Compose-Konfiguration installierter Module speichern',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE modules
|
||||||
|
ADD COLUMN compose_file TEXT,
|
||||||
|
ADD COLUMN app_service TEXT
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
244
apps/platform-backend/src/modules/module-container-manager.ts
Normal file
244
apps/platform-backend/src/modules/module-container-manager.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { stringify, parseDocument } from 'yaml';
|
||||||
|
import { ModuleIdentityService } from './module-identity.service';
|
||||||
|
import type { ModuleRecord } from './manifest.types';
|
||||||
|
|
||||||
|
const SAFE_SERVICE_KEYS = new Set([
|
||||||
|
'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes',
|
||||||
|
'healthcheck', 'working_dir', 'user', 'restart', 'expose', 'networks', 'hostname',
|
||||||
|
'logging', 'mem_limit', 'cpus', 'pids_limit', 'init', 'tmpfs', 'labels',
|
||||||
|
'stop_grace_period', 'read_only', 'tty', 'stdin_open',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */
|
||||||
|
@Injectable()
|
||||||
|
export class ModuleContainerManager {
|
||||||
|
private readonly logger = new Logger('ModuleContainers');
|
||||||
|
private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock';
|
||||||
|
private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? '';
|
||||||
|
|
||||||
|
constructor(private readonly identityService: ModuleIdentityService) {}
|
||||||
|
|
||||||
|
async start(module: ModuleRecord): Promise<void> {
|
||||||
|
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
|
||||||
|
// Recreate stopped containers and project networks before each start. This
|
||||||
|
// prevents Compose v1 from trying to reconcile stale Docker Desktop network
|
||||||
|
// defaults after a stop; named data volumes are deliberately left untouched.
|
||||||
|
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
|
||||||
|
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
|
||||||
|
await this.runDocker(['network', 'rm', gatewayNetwork], true);
|
||||||
|
await this.runDocker(['network', 'create', gatewayNetwork]);
|
||||||
|
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']);
|
||||||
|
if (this.mpmContainer) {
|
||||||
|
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
|
||||||
|
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
|
||||||
|
}
|
||||||
|
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(module: ModuleRecord): Promise<void> {
|
||||||
|
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
|
||||||
|
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']);
|
||||||
|
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
|
||||||
|
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(module: ModuleRecord): Promise<void> {
|
||||||
|
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
|
||||||
|
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
|
||||||
|
// Compose down removes every app/database container and its networks. Named
|
||||||
|
// volumes remain, so uninstalling code does not silently destroy database data.
|
||||||
|
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
|
||||||
|
await this.runDocker(['network', 'rm', gatewayNetwork], true);
|
||||||
|
await rm(overridePath, { force: true });
|
||||||
|
this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepare(module: ModuleRecord): Promise<{
|
||||||
|
composePath: string;
|
||||||
|
overridePath: string;
|
||||||
|
projectName: string;
|
||||||
|
gatewayNetwork: string;
|
||||||
|
}> {
|
||||||
|
if (!module.composeFile || !module.appService) {
|
||||||
|
throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration');
|
||||||
|
}
|
||||||
|
const root = path.resolve(module.path);
|
||||||
|
const composePath = path.resolve(root, module.composeFile);
|
||||||
|
if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets');
|
||||||
|
const source = await readFile(composePath, 'utf8').catch(() => {
|
||||||
|
throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`);
|
||||||
|
});
|
||||||
|
const document = parseDocument(source, { uniqueKeys: true });
|
||||||
|
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
|
||||||
|
const compose = document.toJS() as Record<string, unknown>;
|
||||||
|
this.validateCompose(compose, module);
|
||||||
|
|
||||||
|
const projectName = `mpm-${module.moduleId}`;
|
||||||
|
const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`;
|
||||||
|
// Keep generated secrets outside the package/build context so Dockerfiles
|
||||||
|
// cannot accidentally copy them into an application image.
|
||||||
|
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
|
||||||
|
const override = {
|
||||||
|
version: '3.8',
|
||||||
|
services: {
|
||||||
|
[module.appService]: {
|
||||||
|
container_name: `mpm-${module.moduleId}-app`,
|
||||||
|
environment: {
|
||||||
|
PORT: String(module.internalPort),
|
||||||
|
NODE_ENV: process.env.NODE_ENV ?? 'production',
|
||||||
|
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
|
||||||
|
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
|
||||||
|
},
|
||||||
|
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
|
||||||
|
networks: {
|
||||||
|
default: {},
|
||||||
|
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
|
||||||
|
},
|
||||||
|
security_opt: ['no-new-privileges:true'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
volumes: { 'mpm-runtime-data': {} },
|
||||||
|
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
|
||||||
|
};
|
||||||
|
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(overridePath, stringify(override), { mode: 0o600 });
|
||||||
|
return { composePath, overridePath, projectName, gatewayNetwork };
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void {
|
||||||
|
if (!compose || typeof compose !== 'object' || Array.isArray(compose)) {
|
||||||
|
throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten');
|
||||||
|
}
|
||||||
|
const topLevel = new Set(['version', 'services', 'volumes', 'networks']);
|
||||||
|
if (Object.keys(compose).some((key) => !topLevel.has(key))) {
|
||||||
|
throw new BadRequestException('Compose darf nur services, volumes und networks enthalten');
|
||||||
|
}
|
||||||
|
const services = compose.services;
|
||||||
|
if (!services || typeof services !== 'object' || Array.isArray(services)) {
|
||||||
|
throw new BadRequestException('Compose benötigt mindestens einen Service');
|
||||||
|
}
|
||||||
|
const serviceMap = services as Record<string, unknown>;
|
||||||
|
if (!Object.hasOwn(serviceMap, module.appService!)) {
|
||||||
|
throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`);
|
||||||
|
}
|
||||||
|
const definedNetworks = this.record(compose.networks, 'networks');
|
||||||
|
const definedVolumes = this.record(compose.volumes, 'volumes');
|
||||||
|
if (Object.hasOwn(definedNetworks, 'mpm-gateway') || Object.hasOwn(definedVolumes, 'mpm-runtime-data')) {
|
||||||
|
throw new BadRequestException('Compose verwendet einen für MPM reservierten Netzwerk- oder Volume-Namen');
|
||||||
|
}
|
||||||
|
for (const [name, rawService] of Object.entries(serviceMap)) {
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/.test(name) || !rawService || typeof rawService !== 'object' || Array.isArray(rawService)) {
|
||||||
|
throw new BadRequestException('Compose enthält einen ungültigen Service');
|
||||||
|
}
|
||||||
|
const service = rawService as Record<string, unknown>;
|
||||||
|
if (Object.keys(service).some((key) => !SAFE_SERVICE_KEYS.has(key))) {
|
||||||
|
throw new BadRequestException(`Compose-Service "${name}" enthält nicht erlaubte Optionen`);
|
||||||
|
}
|
||||||
|
if (service.ports !== undefined || service.privileged !== undefined || service.cap_add !== undefined ||
|
||||||
|
service.devices !== undefined || service.network_mode !== undefined || service.pid !== undefined ||
|
||||||
|
service.ipc !== undefined || service.volumes_from !== undefined || service.env_file !== undefined ||
|
||||||
|
service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) {
|
||||||
|
throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`);
|
||||||
|
}
|
||||||
|
if (service.build !== undefined) this.validateBuild(service.build, module.path);
|
||||||
|
if (service.volumes !== undefined) this.validateVolumes(service.volumes);
|
||||||
|
service.security_opt = ['no-new-privileges:true'];
|
||||||
|
}
|
||||||
|
for (const [name, volume] of Object.entries(definedVolumes)) {
|
||||||
|
if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null &&
|
||||||
|
(!volume || typeof volume !== 'object' || Array.isArray(volume) || Object.keys(volume).length > 0))) {
|
||||||
|
throw new BadRequestException('Compose darf nur projektlokale Datenvolumes definieren');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, network] of Object.entries(definedNetworks)) {
|
||||||
|
const networkConfig = network && typeof network === 'object' && !Array.isArray(network)
|
||||||
|
? network as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
if (name === 'mpm-gateway' || (network !== undefined && network !== null &&
|
||||||
|
(!network || typeof network !== 'object' || Array.isArray(network) ||
|
||||||
|
Object.keys(networkConfig).some((key) => !['internal', 'attachable', 'labels'].includes(key))))) {
|
||||||
|
throw new BadRequestException('Compose darf keine externen Netzwerke verwenden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new BadRequestException(`Compose-${label} muss ein Objekt sein`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateBuild(build: unknown, modulePath: string): void {
|
||||||
|
const context = typeof build === 'string'
|
||||||
|
? build
|
||||||
|
: build && typeof build === 'object' && !Array.isArray(build)
|
||||||
|
? String((build as Record<string, unknown>).context ?? '.')
|
||||||
|
: '';
|
||||||
|
if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) {
|
||||||
|
throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen');
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(modulePath, context);
|
||||||
|
if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) {
|
||||||
|
throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateVolumes(volumes: unknown): void {
|
||||||
|
if (!Array.isArray(volumes)) throw new BadRequestException('Compose-Volumes müssen als Liste angegeben werden');
|
||||||
|
for (const volume of volumes) {
|
||||||
|
if (typeof volume !== 'string') throw new BadRequestException('Compose-Volume-Angabe ist ungültig');
|
||||||
|
const parts = volume.split(':');
|
||||||
|
const [source, target, mode] = parts;
|
||||||
|
if (parts.length > 3 || !target || !target.startsWith('/') ||
|
||||||
|
(source && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(source)) ||
|
||||||
|
(mode !== undefined && !/^(ro|rw)(,ro|,rw)?$/.test(mode))) {
|
||||||
|
throw new BadRequestException('Compose darf keine Host-Verzeichnisse mounten');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise<void> {
|
||||||
|
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runDocker(args: string[], ignoreFailure = false): Promise<void> {
|
||||||
|
return this.run('docker', args, process.cwd(), ignoreFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||||
|
// Do not let a root-owned /root/.docker configuration affect a child
|
||||||
|
// command started by the unprivileged backend user.
|
||||||
|
HOME: '/tmp',
|
||||||
|
DOCKER_HOST: this.dockerHost,
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); });
|
||||||
|
child.once('error', (error) => {
|
||||||
|
if (ignoreFailure) resolve();
|
||||||
|
else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`));
|
||||||
|
});
|
||||||
|
child.once('close', (code) => {
|
||||||
|
if (code === 0 || ignoreFailure) resolve();
|
||||||
|
else {
|
||||||
|
this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`);
|
||||||
|
reject(new Error(`${command} schlug mit Status ${code} fehl`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/platform-backend/src/modules/module-filesystem.ts
Normal file
26
apps/platform-backend/src/modules/module-filesystem.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { chmod, lstat, readdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** Make module files readable to runtime users, but never writable. */
|
||||||
|
export async function secureModuleDirectory(directory: string): Promise<void> {
|
||||||
|
const rootInfo = await lstat(directory);
|
||||||
|
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
||||||
|
throw new BadRequestException('Modulverzeichnis muss ein echtes Verzeichnis sein');
|
||||||
|
}
|
||||||
|
await chmod(directory, 0o755);
|
||||||
|
for (const entry of await readdir(directory)) {
|
||||||
|
const entryPath = path.join(directory, entry);
|
||||||
|
const info = await lstat(entryPath);
|
||||||
|
if (info.isSymbolicLink()) {
|
||||||
|
throw new BadRequestException(`Symbolische Links sind im Modul-Paket nicht erlaubt: ${entry}`);
|
||||||
|
}
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
await secureModuleDirectory(entryPath);
|
||||||
|
} else if (info.isFile()) {
|
||||||
|
await chmod(entryPath, 0o644);
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException(`Nicht unterstützter Dateityp im Modul-Paket: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { extractSessionToken } from '../auth/guards/session.guard';
|
|||||||
import { UserRepository } from '../users/user.repository';
|
import { UserRepository } from '../users/user.repository';
|
||||||
import { ModuleRepository } from './module.repository';
|
import { ModuleRepository } from './module.repository';
|
||||||
import { ModulePermissionsService } from './module-permissions.service';
|
import { ModulePermissionsService } from './module-permissions.service';
|
||||||
|
import { ModuleIdentityService } from './module-identity.service';
|
||||||
|
|
||||||
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
|
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
|
||||||
const GATEWAY_PREFIX = '/api/v1/gateway/';
|
const GATEWAY_PREFIX = '/api/v1/gateway/';
|
||||||
@@ -34,6 +35,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
|
|||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly permissionsService: ModulePermissionsService,
|
private readonly permissionsService: ModulePermissionsService,
|
||||||
|
private readonly identityService: ModuleIdentityService = new ModuleIdentityService(),
|
||||||
) {
|
) {
|
||||||
this.proxy = httpProxy.createProxyServer({
|
this.proxy = httpProxy.createProxyServer({
|
||||||
proxyTimeout: 30_000,
|
proxyTimeout: 30_000,
|
||||||
@@ -52,6 +54,21 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
|
|||||||
response.end();
|
response.end();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nest/Express may already have consumed JSON request bodies before this
|
||||||
|
// middleware runs. Replay the parsed body to the module instead of leaving
|
||||||
|
// the proxied request stream empty (which makes module POST handlers hang).
|
||||||
|
this.proxy.on('proxyReq', (proxyRequest, request) => {
|
||||||
|
const contentType = request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase();
|
||||||
|
const parsedBody = (request as Request).body;
|
||||||
|
if (contentType !== 'application/json' || parsedBody === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify(parsedBody);
|
||||||
|
proxyRequest.setHeader('Content-Length', Buffer.byteLength(body));
|
||||||
|
proxyRequest.write(body);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async use(request: Request, response: Response, next: NextFunction): Promise<void> {
|
async use(request: Request, response: Response, next: NextFunction): Promise<void> {
|
||||||
@@ -114,16 +131,19 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Identität sicher an das Modul übergeben (Header, nicht URL)
|
// 5. Identität sicher an das Modul übergeben (Header, nicht URL)
|
||||||
|
request.url = modulePath;
|
||||||
|
const signedIdentity = this.identityService.sign(module.moduleId, request.method, request.url, user);
|
||||||
request.headers['x-user-id'] = user.id;
|
request.headers['x-user-id'] = user.id;
|
||||||
request.headers['x-user-username'] = user.username;
|
request.headers['x-user-username'] = user.username;
|
||||||
request.headers['x-user-display-name'] = user.displayName;
|
request.headers['x-user-display-name'] = user.displayName;
|
||||||
request.headers['x-user-role'] = user.role;
|
request.headers['x-user-role'] = user.role;
|
||||||
|
request.headers['x-mpm-identity-timestamp'] = signedIdentity.timestamp;
|
||||||
|
request.headers['x-mpm-identity-signature'] = signedIdentity.signature;
|
||||||
// Session-Cookie niemals an das Modul weiterleiten
|
// Session-Cookie niemals an das Modul weiterleiten
|
||||||
delete request.headers.cookie;
|
delete request.headers.cookie;
|
||||||
|
|
||||||
request.url = modulePath;
|
|
||||||
this.proxy.web(request, response, {
|
this.proxy.web(request, response, {
|
||||||
target: `http://127.0.0.1:${module.internalPort}`,
|
target: `http://${module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1'}:${module.internalPort}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,8 @@ export class ModuleHealthChecker {
|
|||||||
|
|
||||||
/** Prüft einen Modul-Prozess über seine Healthcheck-URL. */
|
/** Prüft einen Modul-Prozess über seine Healthcheck-URL. */
|
||||||
async check(module: ModuleRecord): Promise<ModuleHealthResult> {
|
async check(module: ModuleRecord): Promise<ModuleHealthResult> {
|
||||||
const url = `http://127.0.0.1:${module.internalPort}${module.healthcheckUrl}`;
|
const host = module.composeFile && module.appService ? `mpm-${module.moduleId}` : '127.0.0.1';
|
||||||
|
const url = `http://${host}:${module.internalPort}${module.healthcheckUrl}`;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
68
apps/platform-backend/src/modules/module-identity.service.ts
Normal file
68
apps/platform-backend/src/modules/module-identity.service.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
|
import type { AuthUser } from '../users/user.types';
|
||||||
|
|
||||||
|
const TIMESTAMP_HEADER = 'x-mpm-identity-timestamp';
|
||||||
|
const SIGNATURE_HEADER = 'x-mpm-identity-signature';
|
||||||
|
const MAX_AGE_MS = 30_000;
|
||||||
|
|
||||||
|
/** Issues module-specific, request-bound identities for the local module gateway. */
|
||||||
|
@Injectable()
|
||||||
|
export class ModuleIdentityService {
|
||||||
|
private readonly masterKey = randomBytes(32);
|
||||||
|
|
||||||
|
keyForModule(moduleId: string): string {
|
||||||
|
return createHmac('sha256', this.masterKey).update(moduleId).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(moduleId: string, method: string, url: string, user: AuthUser): {
|
||||||
|
timestamp: string;
|
||||||
|
signature: string;
|
||||||
|
} {
|
||||||
|
const timestamp = String(Date.now());
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
signature: this.signature(this.keyForModule(moduleId), [
|
||||||
|
moduleId, method, url, timestamp, user.id, user.username, user.displayName, user.role,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(
|
||||||
|
moduleId: string,
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
headers: IncomingHttpHeaders,
|
||||||
|
key: string,
|
||||||
|
): AuthUser | null {
|
||||||
|
const userId = this.readHeader(headers, 'x-user-id');
|
||||||
|
const username = this.readHeader(headers, 'x-user-username');
|
||||||
|
const displayName = this.readHeader(headers, 'x-user-display-name') ?? username;
|
||||||
|
const role = this.readHeader(headers, 'x-user-role');
|
||||||
|
const timestamp = this.readHeader(headers, TIMESTAMP_HEADER);
|
||||||
|
const suppliedSignature = this.readHeader(headers, SIGNATURE_HEADER);
|
||||||
|
if (!userId || !username || !displayName || !timestamp || !suppliedSignature ||
|
||||||
|
(role !== 'ADMIN' && role !== 'USER')) return null;
|
||||||
|
|
||||||
|
const timestampNumber = Number(timestamp);
|
||||||
|
if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > MAX_AGE_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expected = Buffer.from(this.signature(key, [
|
||||||
|
moduleId, method, url, timestamp, userId, username, displayName, role,
|
||||||
|
]), 'hex');
|
||||||
|
const supplied = Buffer.from(suppliedSignature, 'hex');
|
||||||
|
if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return null;
|
||||||
|
return { id: userId, username, displayName, role, email: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private signature(key: string, fields: readonly string[]): string {
|
||||||
|
return createHmac('sha256', key).update(JSON.stringify(fields)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readHeader(headers: IncomingHttpHeaders, name: string): string | null {
|
||||||
|
const value = headers[name];
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
|
import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
|
||||||
|
import { secureModuleDirectory } from './module-filesystem';
|
||||||
|
|
||||||
/** Maximale Größe eines Modul-Pakets (10 MB). */
|
/** Maximale Größe eines Modul-Pakets (10 MB). */
|
||||||
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
@@ -64,7 +65,19 @@ export class ModuleInstaller {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
const manifest = result.data;
|
||||||
|
if (!manifest.composeFile || !manifest.appService) {
|
||||||
|
throw new BadRequestException('module.json muss composeFile und appService für den Containerbetrieb enthalten');
|
||||||
|
}
|
||||||
|
if (manifest.composeFile.startsWith('/') || manifest.composeFile.includes('\\') ||
|
||||||
|
manifest.composeFile.split('/').includes('..')) {
|
||||||
|
throw new BadRequestException('composeFile muss ein relativer Pfad innerhalb des Modulpakets sein');
|
||||||
|
}
|
||||||
|
if (!zip.getEntry(manifest.composeFile)) {
|
||||||
|
throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +112,7 @@ export class ModuleInstaller {
|
|||||||
await mkdir(directory, { recursive: true });
|
await mkdir(directory, { recursive: true });
|
||||||
|
|
||||||
zip.extractAllTo(resolvedDirectory, true);
|
zip.extractAllTo(resolvedDirectory, true);
|
||||||
|
await secureModuleDirectory(resolvedDirectory);
|
||||||
|
|
||||||
// Paket-Metadaten für spätere Diagnose speichern.
|
// Paket-Metadaten für spätere Diagnose speichern.
|
||||||
await writeFile(
|
await writeFile(
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
import { mkdir, open } from 'node:fs/promises';
|
import { chmod, chown, mkdir, open } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import type { ModuleRecord } from './manifest.types';
|
import { MODULE_PORT_MIN, type ModuleRecord } from './manifest.types';
|
||||||
|
import { secureModuleDirectory } from './module-filesystem';
|
||||||
|
import { ModuleIdentityService } from './module-identity.service';
|
||||||
|
import { ModuleContainerManager } from './module-container-manager';
|
||||||
|
|
||||||
/** Laufende Modul-Prozesse im Speicher (nicht persistent). */
|
/** Laufende Modul-Prozesse im Speicher (nicht persistent). */
|
||||||
interface RunningProcess {
|
interface RunningProcess {
|
||||||
@@ -27,21 +30,63 @@ interface RunningProcess {
|
|||||||
export class ModuleProcessManager implements OnModuleDestroy {
|
export class ModuleProcessManager implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger('ModuleProcesses');
|
private readonly logger = new Logger('ModuleProcesses');
|
||||||
private readonly running = new Map<string, RunningProcess>();
|
private readonly running = new Map<string, RunningProcess>();
|
||||||
|
private readonly containerModules = new Map<string, ModuleRecord>();
|
||||||
|
|
||||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
constructor(
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
private readonly identityService: ModuleIdentityService,
|
||||||
|
private readonly containerManager: ModuleContainerManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** Startet einen Modul-Prozess. */
|
/** Startet einen Modul-Prozess. */
|
||||||
async start(module: ModuleRecord): Promise<void> {
|
async start(module: ModuleRecord): Promise<void> {
|
||||||
if (this.running.has(module.moduleId)) {
|
if (this.running.has(module.moduleId) || this.containerModules.has(module.moduleId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module.composeFile && module.appService) {
|
||||||
|
await secureModuleDirectory(module.path);
|
||||||
|
await this.containerManager.start(module);
|
||||||
|
this.containerModules.set(module.moduleId, module);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoint = path.join(module.path, 'backend', 'server.js');
|
const entrypoint = path.join(module.path, 'backend', 'server.js');
|
||||||
const logFilePath = path.join(this.config.runtime.logsDir, `module-${module.moduleId}.log`);
|
const logFilePath = path.join(this.config.runtime.logsDir, `module-${module.moduleId}.log`);
|
||||||
await mkdir(this.config.runtime.logsDir, { recursive: true });
|
await secureModuleDirectory(module.path);
|
||||||
const logFile = await open(logFilePath, 'a');
|
const moduleUid =
|
||||||
|
process.platform !== 'win32' && this.config.runtime.moduleUidBase !== undefined
|
||||||
|
? this.config.runtime.moduleUidBase + module.internalPort - MODULE_PORT_MIN
|
||||||
|
: undefined;
|
||||||
|
const legacyDataDir = path.join(module.path, 'data');
|
||||||
|
await mkdir(legacyDataDir, { recursive: true, mode: 0o700 });
|
||||||
|
if (moduleUid !== undefined) await chown(legacyDataDir, moduleUid, moduleUid);
|
||||||
|
await mkdir(this.config.runtime.logsDir, { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(this.config.runtime.logsDir, 0o700);
|
||||||
|
const logFile = await open(logFilePath, 'a', 0o600);
|
||||||
|
await chmod(logFilePath, 0o600);
|
||||||
|
|
||||||
const child = spawn(process.execPath, [entrypoint], {
|
// Unique UID/GID per internal port prevents modules from reading or tracing
|
||||||
|
// each other's processes. Production Compose grants only SETUID/SETGID.
|
||||||
|
// The platform backend receives SETUID/SETGID/KILL as ambient capabilities from
|
||||||
|
// supervisord. Use setpriv to change the module identity, then clear all
|
||||||
|
// inheritable/ambient capabilities before executing untrusted module code.
|
||||||
|
const moduleCommand = moduleUid !== undefined ? '/usr/bin/setpriv' : process.execPath;
|
||||||
|
const moduleArgs =
|
||||||
|
moduleUid !== undefined
|
||||||
|
? [
|
||||||
|
`--reuid=${moduleUid}`,
|
||||||
|
`--regid=${moduleUid}`,
|
||||||
|
'--clear-groups',
|
||||||
|
'--inh-caps=-all',
|
||||||
|
'--ambient-caps=-all',
|
||||||
|
'--no-new-privs',
|
||||||
|
'--',
|
||||||
|
process.execPath,
|
||||||
|
entrypoint,
|
||||||
|
]
|
||||||
|
: [entrypoint];
|
||||||
|
const child = spawn(moduleCommand, moduleArgs, {
|
||||||
cwd: module.path,
|
cwd: module.path,
|
||||||
// Log-Datei bleibt offen: Der fd wird vom Kindprozess geerbt und
|
// Log-Datei bleibt offen: Der fd wird vom Kindprozess geerbt und
|
||||||
// darf erst nach Prozessende geschlossen werden.
|
// darf erst nach Prozessende geschlossen werden.
|
||||||
@@ -50,6 +95,8 @@ export class ModuleProcessManager implements OnModuleDestroy {
|
|||||||
PATH: process.env.PATH ?? '',
|
PATH: process.env.PATH ?? '',
|
||||||
NODE_ENV: this.config.nodeEnv,
|
NODE_ENV: this.config.nodeEnv,
|
||||||
PORT: String(module.internalPort),
|
PORT: String(module.internalPort),
|
||||||
|
MPM_MODULE_DATA_DIR: legacyDataDir,
|
||||||
|
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
|
||||||
// Modul erhält nur seinen eigenen Kontext – keine Plattform-Secrets.
|
// Modul erhält nur seinen eigenen Kontext – keine Plattform-Secrets.
|
||||||
},
|
},
|
||||||
detached: false,
|
detached: false,
|
||||||
@@ -68,6 +115,12 @@ export class ModuleProcessManager implements OnModuleDestroy {
|
|||||||
|
|
||||||
/** Stoppt einen Modul-Prozess (SIGTERM, dann SIGKILL). */
|
/** Stoppt einen Modul-Prozess (SIGTERM, dann SIGKILL). */
|
||||||
async stop(moduleId: string): Promise<void> {
|
async stop(moduleId: string): Promise<void> {
|
||||||
|
const containerModule = this.containerModules.get(moduleId);
|
||||||
|
if (containerModule) {
|
||||||
|
await this.containerManager.stop(containerModule);
|
||||||
|
this.containerModules.delete(moduleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const process_ = this.running.get(moduleId);
|
const process_ = this.running.get(moduleId);
|
||||||
if (!process_) {
|
if (!process_) {
|
||||||
return;
|
return;
|
||||||
@@ -93,6 +146,13 @@ export class ModuleProcessManager implements OnModuleDestroy {
|
|||||||
this.logger.log(`Modul-Prozess "${moduleId}" gestoppt`);
|
this.logger.log(`Modul-Prozess "${moduleId}" gestoppt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(module: ModuleRecord): Promise<void> {
|
||||||
|
if (module.composeFile && module.appService) {
|
||||||
|
await this.containerManager.remove(module);
|
||||||
|
this.containerModules.delete(module.moduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Prüft, ob ein Modul-Prozess läuft. */
|
/** Prüft, ob ein Modul-Prozess läuft. */
|
||||||
isRunning(moduleId: string): boolean {
|
isRunning(moduleId: string): boolean {
|
||||||
return this.running.has(moduleId);
|
return this.running.has(moduleId);
|
||||||
@@ -100,7 +160,7 @@ export class ModuleProcessManager implements OnModuleDestroy {
|
|||||||
|
|
||||||
/** Stoppt alle Modul-Prozesse (Herunterfahren). */
|
/** Stoppt alle Modul-Prozesse (Herunterfahren). */
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
const moduleIds = [...this.running.keys()];
|
const moduleIds = [...this.running.keys(), ...this.containerModules.keys()];
|
||||||
await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId)));
|
await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ interface ModuleRow {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
|
compose_file: string | null;
|
||||||
|
app_service: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
|
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
|
||||||
status, internal_port, healthcheck_url, enabled, created_at, updated_at`;
|
status, internal_port, healthcheck_url, enabled, created_at, updated_at,
|
||||||
|
compose_file, app_service`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry.
|
* Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry.
|
||||||
@@ -80,8 +83,9 @@ export class ModuleRepository {
|
|||||||
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
|
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
|
||||||
const result = await this.database.query<ModuleRow>(
|
const result = await this.database.query<ModuleRow>(
|
||||||
`INSERT INTO modules
|
`INSERT INTO modules
|
||||||
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url)
|
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9)
|
compose_file, app_service)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11)
|
||||||
RETURNING ${MODULE_COLUMNS}`,
|
RETURNING ${MODULE_COLUMNS}`,
|
||||||
[
|
[
|
||||||
manifest.id,
|
manifest.id,
|
||||||
@@ -93,6 +97,8 @@ export class ModuleRepository {
|
|||||||
directory,
|
directory,
|
||||||
manifest.port,
|
manifest.port,
|
||||||
manifest.healthcheck,
|
manifest.healthcheck,
|
||||||
|
manifest.composeFile ?? null,
|
||||||
|
manifest.appService ?? null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return this.mapRow(result.rows[0]);
|
return this.mapRow(result.rows[0]);
|
||||||
@@ -132,6 +138,8 @@ export class ModuleRepository {
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
composeFile: row.compose_file,
|
||||||
|
appService: row.app_service,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,15 +21,21 @@ import { ModuleRepository } from './module.repository';
|
|||||||
import { ModuleStartupRecovery } from './module-startup-recovery';
|
import { ModuleStartupRecovery } from './module-startup-recovery';
|
||||||
import { ModulesController } from './modules.controller';
|
import { ModulesController } from './modules.controller';
|
||||||
import { ModulesService } from './modules.service';
|
import { ModulesService } from './modules.service';
|
||||||
|
import { ModuleIdentityService } from './module-identity.service';
|
||||||
|
import { MarketplaceController } from './marketplace.controller';
|
||||||
|
import { MarketplaceService } from './marketplace.service';
|
||||||
|
import { ModuleContainerManager } from './module-container-manager';
|
||||||
|
|
||||||
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
|
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, DatabaseModule, AuditModule],
|
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||||
controllers: [ModulesController, ModulePermissionsController],
|
controllers: [ModulesController, ModulePermissionsController, MarketplaceController],
|
||||||
providers: [
|
providers: [
|
||||||
ModuleRepository,
|
ModuleRepository,
|
||||||
ModuleInstaller,
|
ModuleInstaller,
|
||||||
ModuleProcessManager,
|
ModuleProcessManager,
|
||||||
|
ModuleIdentityService,
|
||||||
|
ModuleContainerManager,
|
||||||
ModuleHealthChecker,
|
ModuleHealthChecker,
|
||||||
ModulesService,
|
ModulesService,
|
||||||
SessionService,
|
SessionService,
|
||||||
@@ -39,6 +45,7 @@ import { ModulesService } from './modules.service';
|
|||||||
ModuleStartupRecovery,
|
ModuleStartupRecovery,
|
||||||
ModulePermissionRepository,
|
ModulePermissionRepository,
|
||||||
ModulePermissionsService,
|
ModulePermissionsService,
|
||||||
|
MarketplaceService,
|
||||||
],
|
],
|
||||||
exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker],
|
exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ const TEST_CONFIG: AppConfig = {
|
|||||||
},
|
},
|
||||||
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
||||||
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
||||||
|
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ModulesService', () => {
|
describe('ModulesService', () => {
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export class ModulesService {
|
|||||||
return module;
|
return module;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validatePackage(packageBuffer: Buffer) {
|
||||||
|
return this.installer.validatePackage(packageBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
|
||||||
|
return this.moduleRepository.findByModuleId(moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
|
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
|
||||||
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
|
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
|
||||||
const module = await this.getById(id);
|
const module = await this.getById(id);
|
||||||
@@ -210,6 +218,8 @@ export class ModulesService {
|
|||||||
await this.stop(id, actor, ipAddress);
|
await this.stop(id, actor, ipAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.processManager.remove(module);
|
||||||
|
|
||||||
await this.moduleRepository.delete(id);
|
await this.moduleRepository.delete(id);
|
||||||
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
|
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { PasswordHasher } from './password-hasher';
|
import { PasswordHasher } from './password-hasher';
|
||||||
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
|
import type { CreateUserDto, RoleName, UpdateUserDto, UserRecord } from './user.types';
|
||||||
@@ -108,7 +108,29 @@ export class UserRepository {
|
|||||||
setClauses.push('updated_at = now()');
|
setClauses.push('updated_at = now()');
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
const result = await this.database.query<UserRow>(
|
return this.database.transaction(async (client) => {
|
||||||
|
await client.query('SELECT pg_advisory_xact_lock(727273)');
|
||||||
|
if (changes.role === 'USER' || changes.isActive === false) {
|
||||||
|
const current = await client.query<{ role_name: RoleName; is_active: boolean }>(
|
||||||
|
`SELECT r.name AS role_name, u.is_active
|
||||||
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE u.id = $1 FOR UPDATE OF u`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (current.rows[0]?.role_name === 'ADMIN' && current.rows[0].is_active) {
|
||||||
|
const count = await client.query<{ count: number }>(
|
||||||
|
`SELECT count(*)::int AS count
|
||||||
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE r.name = 'ADMIN' AND u.is_active`,
|
||||||
|
);
|
||||||
|
if ((count.rows[0]?.count ?? 0) <= 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await client.query<UserRow>(
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET ${setClauses.join(', ')}
|
SET ${setClauses.join(', ')}
|
||||||
WHERE id = $${parameterIndex}
|
WHERE id = $${parameterIndex}
|
||||||
@@ -118,7 +140,9 @@ export class UserRepository {
|
|||||||
last_login_at, created_at, updated_at`,
|
last_login_at, created_at, updated_at`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
if (!result.rows[0]) throw new BadRequestException('Benutzer nicht gefunden');
|
||||||
return this.mapRow(result.rows[0]);
|
return this.mapRow(result.rows[0]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Setzt einen neuen Passwort-Hash. */
|
/** Setzt einen neuen Passwort-Hash. */
|
||||||
@@ -138,7 +162,26 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await this.database.query('DELETE FROM users WHERE id = $1', [id]);
|
await this.database.transaction(async (client) => {
|
||||||
|
await client.query('SELECT pg_advisory_xact_lock(727273)');
|
||||||
|
const current = await client.query<{ role_name: RoleName; is_active: boolean }>(
|
||||||
|
`SELECT r.name AS role_name, u.is_active
|
||||||
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE u.id = $1 FOR UPDATE OF u`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (current.rows[0]?.role_name === 'ADMIN' && current.rows[0].is_active) {
|
||||||
|
const count = await client.query<{ count: number }>(
|
||||||
|
`SELECT count(*)::int AS count
|
||||||
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE r.name = 'ADMIN' AND u.is_active`,
|
||||||
|
);
|
||||||
|
if ((count.rows[0]?.count ?? 0) <= 1) {
|
||||||
|
throw new BadRequestException('Der letzte aktive Administrator kann nicht gelöscht werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('DELETE FROM users WHERE id = $1', [id]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */
|
/** Anzahl aktiver Administratoren (Schutz vor Verlust des letzten Admins). */
|
||||||
@@ -163,21 +206,13 @@ export class UserRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLoginFailure(
|
async updateLoginFailure(userId: string): Promise<void> {
|
||||||
userId: string,
|
|
||||||
attempts: number,
|
|
||||||
shouldLock: boolean,
|
|
||||||
lockoutMinutes: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.database.query(
|
await this.database.query(
|
||||||
`UPDATE users
|
`UPDATE users
|
||||||
SET failed_login_attempts = $2,
|
SET failed_login_attempts = failed_login_attempts + 1,
|
||||||
locked_until = CASE WHEN $3::boolean
|
|
||||||
THEN now() + make_interval(mins => $4::int)
|
|
||||||
ELSE locked_until END,
|
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[userId, attempts, shouldLock, lockoutMinutes],
|
[userId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useAuth } from '../../features/auth/auth-context';
|
import { useAuth } from '../../features/auth/auth-context';
|
||||||
import { Badge } from '../ui/badge';
|
import { ProfilePage } from '../../features/profile/profile-page';
|
||||||
|
import { Icon, type IconName } from '../ui/icon';
|
||||||
|
|
||||||
/** Ein Navigationspunkt der Sidebar. */
|
/** Ein Navigationspunkt der Sidebar. */
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: IconName;
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
{ to: '/', label: 'Dashboard', icon: 'dashboard' },
|
||||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
{ to: '/admin/users', label: 'Benutzer', icon: 'users', adminOnly: true },
|
||||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
{ to: '/admin/modules', label: 'Module', icon: 'modules', adminOnly: true },
|
||||||
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
|
{ to: '/admin/system', label: 'Systemstatus', icon: 'system', adminOnly: true },
|
||||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
{ to: '/admin/audit', label: 'Audit-Log', icon: 'audit', adminOnly: true },
|
||||||
{ to: '/admin/audit', label: 'Audit-Log', icon: '📋', adminOnly: true },
|
|
||||||
{ to: '/admin/settings', label: 'Einstellungen', icon: '🔧', adminOnly: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function initialDarkMode(): boolean {
|
||||||
|
return document.documentElement.dataset.theme === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
|
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
|
||||||
export function AppLayout(): ReactNode {
|
export function AppLayout(): ReactNode {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(initialDarkMode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = darkMode ? 'dark' : 'light';
|
||||||
|
localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light');
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
const visibleItems = NAV_ITEMS.filter(
|
const visibleItems = NAV_ITEMS.filter(
|
||||||
(item) => !item.adminOnly || user?.role === 'ADMIN',
|
(item) => !item.adminOnly || user?.role === 'ADMIN',
|
||||||
@@ -70,22 +80,28 @@ export function AppLayout(): ReactNode {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{item.icon}</span>
|
<Icon name={item.icon} className="h-[18px] w-[18px]" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-slate-200 p-4">
|
<div className="border-t border-slate-200 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<div className="min-w-0">
|
type="button"
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
onClick={() => setProfileOpen(true)}
|
||||||
|
className="group flex w-full min-w-0 items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={`Profileinstellungen für ${user?.displayName ?? 'Benutzer'} öffnen`}
|
||||||
|
>
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600">
|
||||||
|
<Icon name="user" className="h-[18px] w-[18px]" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 group-hover:text-brand-700">
|
||||||
{user?.displayName}
|
{user?.displayName}
|
||||||
</p>
|
</span>
|
||||||
<p className="truncate text-xs text-slate-500">@{user?.username}</p>
|
<Icon name="arrow" className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</button>
|
||||||
{user?.role === 'ADMIN' && <Badge variant="info">Admin</Badge>}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => void logout()}
|
onClick={() => void logout()}
|
||||||
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
@@ -105,18 +121,31 @@ export function AppLayout(): ReactNode {
|
|||||||
aria-label="Menü öffnen"
|
aria-label="Menü öffnen"
|
||||||
aria-expanded={mobileMenuOpen}
|
aria-expanded={mobileMenuOpen}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="text-xl">☰</span>
|
<Icon name="menu" className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="hidden text-sm text-slate-500 lg:block">
|
<span className="hidden text-sm text-slate-500 lg:block">Management-Plattform</span>
|
||||||
Management-Plattform
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
|
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={darkMode}
|
||||||
|
aria-label="Darkmode"
|
||||||
|
onClick={() => setDarkMode((current) => !current)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
|
||||||
|
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
|
||||||
|
<span className={`relative h-5 w-9 rounded-full transition-colors ${darkMode ? 'bg-brand-600' : 'bg-slate-300'}`}>
|
||||||
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${darkMode ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{profileOpen && <ProfilePage onClose={() => setProfileOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface CardProps {
|
|||||||
export function Card({ children, className = '' }: CardProps): ReactNode {
|
export function Card({ children, className = '' }: CardProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
|
className={`rounded-2xl border border-slate-200 bg-white shadow-sm ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +25,7 @@ export interface CardHeaderProps {
|
|||||||
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
|
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
|
||||||
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
|
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
|
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||||
@@ -42,5 +42,5 @@ export interface CardBodyProps {
|
|||||||
|
|
||||||
/** Karten-Inhalt. */
|
/** Karten-Inhalt. */
|
||||||
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
|
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
|
||||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
return <div className={`px-5 py-5 ${className}`}>{children}</div>;
|
||||||
}
|
}
|
||||||
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check';
|
||||||
|
|
||||||
|
const PATHS: Record<IconName, ReactNode> = {
|
||||||
|
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></>,
|
||||||
|
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="10" cy="7" r="4" /><path d="M20 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /></>,
|
||||||
|
modules: <><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></>,
|
||||||
|
system: <><path d="M12 8v4l2.5 2.5" /><circle cx="12" cy="12" r="9" /><path d="M12 3v2m9 7h-2M5 12H3m9 9v-2" /></>,
|
||||||
|
audit: <><path d="M8 4h10a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2Z" /><path d="M8 2v4m0 4h8m-8 4h8m-8 4h5" /></>,
|
||||||
|
moon: <path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z" />,
|
||||||
|
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42" /></>,
|
||||||
|
user: <><circle cx="12" cy="8" r="4" /><path d="M5 21a7 7 0 0 1 14 0" /></>,
|
||||||
|
arrow: <><path d="M5 12h14m-6-6 6 6-6 6" /></>,
|
||||||
|
close: <><path d="m18 6-12 12M6 6l12 12" /></>,
|
||||||
|
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
|
||||||
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
|
check: <path d="m5 12 4 4L19 6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{PATHS[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ export interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
panelClassName?: string;
|
||||||
|
hideHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
|
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
|
||||||
@@ -17,6 +19,8 @@ export function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
|
panelClassName = 'max-w-md',
|
||||||
|
hideHeader = false,
|
||||||
}: ModalProps): ReactNode {
|
}: ModalProps): ReactNode {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -45,13 +49,15 @@ export function Modal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{!hideHeader && (
|
||||||
<div className="border-b border-slate-200 px-6 py-4">
|
<div className="border-b border-slate-200 px-6 py-4">
|
||||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="px-6 py-4">{children}</div>
|
<div className="px-6 py-4">{children}</div>
|
||||||
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
|
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export function AuditPage(): ReactNode {
|
|||||||
const totalPages = auditQuery.data ? Math.ceil(auditQuery.data.total / pageSize) : 0;
|
const totalPages = auditQuery.data ? Math.ceil(auditQuery.data.total / pageSize) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Audit-Log</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Audit-Log</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-2 text-base text-slate-500">
|
||||||
Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst).
|
Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { type ReactNode, useRef, useState } from 'react';
|
import { type ReactNode, useRef, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Icon } from '../../components/ui/icon';
|
||||||
import { Modal } from '../../components/ui/modal';
|
import { Modal } from '../../components/ui/modal';
|
||||||
import { useToast } from '../../components/ui/toast';
|
import { useToast } from '../../components/ui/toast';
|
||||||
import { ApiError } from '../../lib/api-client';
|
import { ApiError } from '../../lib/api-client';
|
||||||
import {
|
import {
|
||||||
checkModuleHealth,
|
checkModuleHealth,
|
||||||
|
beginMarketplaceConnection,
|
||||||
|
disconnectMarketplaceProvider,
|
||||||
|
fetchMarketplaceRepositories,
|
||||||
|
fetchMarketplaceProviders,
|
||||||
fetchModules,
|
fetchModules,
|
||||||
installModule,
|
installModule,
|
||||||
|
installMarketplaceRepository,
|
||||||
removeModule,
|
removeModule,
|
||||||
restartModule,
|
restartModule,
|
||||||
setModuleEnabled,
|
setModuleEnabled,
|
||||||
startModule,
|
startModule,
|
||||||
stopModule,
|
stopModule,
|
||||||
|
type MarketplaceProvider,
|
||||||
} from '../../lib/modules-api';
|
} from '../../lib/modules-api';
|
||||||
import type { Module, ModuleStatus } from '../../lib/schemas';
|
import type { Module, ModuleStatus } from '../../lib/schemas';
|
||||||
|
|
||||||
@@ -43,6 +50,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
|||||||
mutationFn: () => removeModule(module.id),
|
mutationFn: () => removeModule(module.id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||||
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
@@ -55,7 +63,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
|||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
title="Modul entfernen"
|
title="Modul entfernen"
|
||||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
|
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{formError && (
|
{formError && (
|
||||||
@@ -86,6 +94,8 @@ export function ModulesPage(): ReactNode {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
|
||||||
|
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
|
||||||
|
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
|
||||||
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
|
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
|
||||||
|
|
||||||
const modulesQuery = useQuery({
|
const modulesQuery = useQuery({
|
||||||
@@ -93,6 +103,45 @@ export function ModulesPage(): ReactNode {
|
|||||||
queryFn: fetchModules,
|
queryFn: fetchModules,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
const marketplaceQuery = useQuery({
|
||||||
|
queryKey: ['marketplace-providers'],
|
||||||
|
queryFn: fetchMarketplaceProviders,
|
||||||
|
});
|
||||||
|
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
|
||||||
|
const repositoriesQuery = useQuery({
|
||||||
|
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
|
||||||
|
queryFn: async () => Promise.all(connectedProviders.map(async (item) => ({
|
||||||
|
...item,
|
||||||
|
repositories: await fetchMarketplaceRepositories(item.provider),
|
||||||
|
}))),
|
||||||
|
enabled: connectedProviders.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectMutation = useMutation({
|
||||||
|
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => beginMarketplaceConnection(provider),
|
||||||
|
onSuccess: (authorizationUrl) => window.location.assign(authorizationUrl),
|
||||||
|
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht gestartet werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const disconnectMutation = useMutation({
|
||||||
|
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => disconnectMarketplaceProvider(provider),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marketplace-providers'] });
|
||||||
|
showToast('success', 'Forge-Verbindung getrennt');
|
||||||
|
},
|
||||||
|
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht getrennt werden'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketplaceInstallMutation = useMutation({
|
||||||
|
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
|
||||||
|
installMarketplaceRepository(input.provider, input.owner, input.repository),
|
||||||
|
onSuccess: (module) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||||
|
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
|
||||||
|
},
|
||||||
|
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'),
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = (): void => {
|
const invalidate = (): void => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||||
@@ -163,17 +212,98 @@ export function ModulesPage(): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-base text-slate-500">
|
||||||
Module installieren, starten, stoppen und entfernen.
|
Module installieren, starten, stoppen und entfernen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installation */}
|
{new URLSearchParams(window.location.search).get('marketplace') === 'connected' && (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
<p role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
Forge-Konto wurde erfolgreich verbunden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{new URLSearchParams(window.location.search).get('marketplace') === 'denied' && (
|
||||||
|
<p role="status" className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Autorisierung wurde abgebrochen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{new URLSearchParams(window.location.search).get('marketplace') === 'error' && (
|
||||||
|
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{new URLSearchParams(window.location.search).get('reason') === 'session'
|
||||||
|
? 'MPM konnte deine Sitzung beim OAuth-Rücksprung nicht zuordnen. Verwende vor und nach der Anmeldung dieselbe Adresse (http://127.0.0.1:8081) und starte die Verbindung erneut.'
|
||||||
|
: new URLSearchParams(window.location.search).get('reason') === 'callback'
|
||||||
|
? 'Die OAuth-Rückgabe war unvollständig. Bitte starte die Verbindung erneut.'
|
||||||
|
: 'Verbindung fehlgeschlagen. Prüfe OAuth-Konfiguration und Callback-URL und starte die Verbindung erneut.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Compact provider connectors */}
|
||||||
|
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
|
||||||
|
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
|
||||||
|
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||||
|
Verbindungen
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||||
|
Manuelle Installation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{connectionTab === 'providers' ? (
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
|
{(['github', 'gitea', 'forgejo'] as const).map((provider) => {
|
||||||
|
const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
|
||||||
|
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
|
||||||
|
const statusText = marketplaceQuery.isLoading
|
||||||
|
? 'Status wird geladen…'
|
||||||
|
: connection?.connected
|
||||||
|
? `Verbunden als ${connection.accountLogin}`
|
||||||
|
: connection?.configured
|
||||||
|
? 'Marketplace verbinden'
|
||||||
|
: 'Nicht eingerichtet';
|
||||||
|
return (
|
||||||
|
<div key={provider} className="flex min-w-0 items-center gap-3 border-b border-slate-200 py-3 last:border-b-0">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||||
|
<ProviderMark provider={provider} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">{label}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">{statusText}</p>
|
||||||
|
</div>
|
||||||
|
{connection?.connected ? (
|
||||||
|
<Button
|
||||||
|
className="h-9 w-9 shrink-0 p-0"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${label}-Verbindung trennen`}
|
||||||
|
title={`${label}-Verbindung trennen`}
|
||||||
|
loading={disconnectMutation.isPending && disconnectMutation.variables === provider}
|
||||||
|
onClick={() => disconnectMutation.mutate(provider)}
|
||||||
|
>
|
||||||
|
<Icon name="check" className="h-4 w-4 text-emerald-600" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="h-9 w-9 shrink-0 p-0"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Mit ${label} verbinden`}
|
||||||
|
title={connection?.configured ? `Mit ${label} verbinden` : `OAuth-Konfiguration für ${label} fehlt`}
|
||||||
|
disabled={!connection?.configured || marketplaceQuery.isLoading}
|
||||||
|
loading={connectMutation.isPending && connectMutation.variables === provider}
|
||||||
|
onClick={() => setConnectTarget(provider)}
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div role="tabpanel" className="flex flex-wrap items-center gap-3">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -182,171 +312,218 @@ export function ModulesPage(): ReactNode {
|
|||||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||||
aria-label="Modul-Paket (ZIP) auswählen"
|
aria-label="Modul-Paket (ZIP) auswählen"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
|
||||||
disabled={!selectedFile}
|
|
||||||
loading={installMutation.isPending}
|
|
||||||
onClick={handleInstall}
|
|
||||||
>
|
|
||||||
Installieren
|
Installieren
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
|
||||||
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</section>
|
||||||
{/* Modul-Liste */}
|
{/* Modul-Liste */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
|
||||||
<thead>
|
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
|
||||||
|
<table className="w-full min-w-[900px] table-fixed text-sm">
|
||||||
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
<th className="px-4 py-3 font-semibold">Modul</th>
|
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th>
|
||||||
<th className="px-4 py-3 font-semibold">Status</th>
|
<th className="w-[13%] px-4 py-3 font-semibold">Status</th>
|
||||||
<th className="px-4 py-3 font-semibold">URL</th>
|
<th className="w-[15%] px-4 py-3 font-semibold">URL</th>
|
||||||
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{modulesQuery.isLoading && (
|
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>}
|
||||||
<tr>
|
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>}
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
|
|
||||||
Module werden geladen…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{modulesQuery.isError && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
|
|
||||||
Module konnten nicht geladen werden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{modulesQuery.data?.map((module) => (
|
{modulesQuery.data?.map((module) => (
|
||||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium text-slate-900">{module.name}</div>
|
<div className="font-semibold text-slate-900">{module.name}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div>
|
||||||
{module.moduleId} · Version {module.version}
|
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
|
||||||
{module.author && ` · ${module.author}`}
|
|
||||||
</div>
|
|
||||||
{module.description && (
|
|
||||||
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3"><span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span>
|
||||||
<span
|
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
|
||||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
|
||||||
${
|
|
||||||
statusVariant(module.status) === 'success'
|
|
||||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
|
|
||||||
: statusVariant(module.status) === 'warning'
|
|
||||||
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
|
|
||||||
: statusVariant(module.status) === 'danger'
|
|
||||||
? 'bg-red-50 text-red-700 ring-red-600/20'
|
|
||||||
: statusVariant(module.status) === 'neutral'
|
|
||||||
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
|
||||||
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{module.status}
|
|
||||||
</span>
|
|
||||||
{healthResults[module.id] && (
|
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
|
||||||
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
|
|
||||||
/{module.slug}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{module.status !== 'RUNNING' && module.enabled && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
loading={
|
|
||||||
lifecycleMutation.isPending &&
|
|
||||||
lifecycleMutation.variables?.module.id === module.id &&
|
|
||||||
lifecycleMutation.variables?.action === 'start'
|
|
||||||
}
|
|
||||||
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
loading={
|
|
||||||
lifecycleMutation.isPending &&
|
|
||||||
lifecycleMutation.variables?.module.id === module.id &&
|
|
||||||
lifecycleMutation.variables?.action === 'stop'
|
|
||||||
}
|
|
||||||
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
loading={
|
|
||||||
lifecycleMutation.isPending &&
|
|
||||||
lifecycleMutation.variables?.module.id === module.id &&
|
|
||||||
lifecycleMutation.variables?.action === 'restart'
|
|
||||||
}
|
|
||||||
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => healthMutation.mutate(module)}
|
|
||||||
>
|
|
||||||
Health
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
loading={
|
|
||||||
lifecycleMutation.isPending &&
|
|
||||||
lifecycleMutation.variables?.module.id === module.id &&
|
|
||||||
(lifecycleMutation.variables?.action === 'enable' ||
|
|
||||||
lifecycleMutation.variables?.action === 'disable')
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
lifecycleMutation.mutate({
|
|
||||||
module,
|
|
||||||
action: module.enabled ? 'disable' : 'enable',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{module.enabled ? 'Disable' : 'Enable'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td>
|
||||||
|
<td className="px-4 py-3"><div className="flex flex-wrap gap-1">
|
||||||
|
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
|
||||||
|
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
|
||||||
|
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button>
|
||||||
|
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>Remove</Button>
|
||||||
|
</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{modulesQuery.data?.length === 0 && (
|
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
|
||||||
<p className="px-4 py-8 text-center text-slate-500">
|
</div>
|
||||||
Noch keine Module installiert.
|
</section>
|
||||||
|
</div>
|
||||||
|
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
|
||||||
|
<p className="mt-1 text-base text-slate-500">
|
||||||
|
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connectedProviders.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||||
|
Verbinde zuerst GitHub, Gitea oder Forgejo.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
|
||||||
|
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
|
||||||
|
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
|
||||||
|
{repositoriesQuery.data?.map((provider) => (
|
||||||
|
<div key={provider.provider}>
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
|
||||||
|
{provider.repositories.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Keine öffentlichen Repositories gefunden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{provider.repositories.map((repo) => (
|
||||||
|
<article key={repo.owner + '/' + repo.repository} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 transition-colors hover:border-slate-400 hover:bg-slate-50">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-800"><ProviderMark provider={provider.provider} /></span>
|
||||||
|
<a className="truncate text-sm font-medium text-brand-700 hover:underline" href={repo.htmlUrl} target="_blank" rel="noreferrer">
|
||||||
|
{repo.owner}/{repo.repository}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{repo.description && <p className="mt-1 line-clamp-2 text-xs text-slate-500">{repo.description}</p>}
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Branch: {repo.defaultBranch}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={repo.installed ? 'secondary' : 'primary'}
|
||||||
|
disabled={repo.installed}
|
||||||
|
className={repo.installed ? '!border-emerald-600 !bg-emerald-600 !text-white hover:!bg-emerald-600 disabled:!text-white' : ''}
|
||||||
|
loading={marketplaceInstallMutation.isPending && marketplaceInstallMutation.variables?.repository === repo.repository}
|
||||||
|
onClick={() => marketplaceInstallMutation.mutate({ provider: provider.provider, owner: repo.owner, repository: repo.repository })}
|
||||||
|
>
|
||||||
|
{repo.installed ? 'Installiert' : 'Installieren'}
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{removeTarget && (
|
{removeTarget && (
|
||||||
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
|
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
|
||||||
)}
|
)}
|
||||||
|
{connectTarget && (
|
||||||
|
<ConnectProviderModal
|
||||||
|
provider={connectTarget}
|
||||||
|
label={marketplaceQuery.data?.find((item) => item.provider === connectTarget)?.label ??
|
||||||
|
(connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')}
|
||||||
|
loading={connectMutation.isPending}
|
||||||
|
onClose={() => setConnectTarget(null)}
|
||||||
|
onContinue={() => connectMutation.mutate(connectTarget)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode {
|
||||||
|
if (provider === 'github') {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" fill="currentColor">
|
||||||
|
<path d="M12 .9a11.1 11.1 0 0 0-3.51 21.63c.56.1.76-.24.76-.54v-2.08c-3.1.67-3.76-1.32-3.76-1.32-.51-1.3-1.24-1.65-1.24-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 .1.95 2.17 3.64 1.54.1-.74.39-1.25.7-1.54-2.48-.28-5.09-1.24-5.09-5.52 0-1.22.44-2.22 1.16-3-.12-.29-.5-1.43.11-2.98 0 0 .95-.3 3.05 1.15a10.6 10.6 0 0 1 5.55 0c2.1-1.45 3.04-1.15 3.04-1.15.61 1.55.23 2.69.12 2.98.72.78 1.15 1.78 1.15 3 0 4.3-2.61 5.23-5.1 5.5.4.35.75 1.02.75 2.06v3.04c0 .3.2.65.77.54A11.1 11.1 0 0 0 12 .9Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (provider === 'gitea') {
|
||||||
|
return <span className="text-xl font-bold tracking-tight text-orange-500" aria-hidden="true">G</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-6 w-6 text-emerald-500" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M4 18h16l-3-4h-4l-2-4H8l2 4H7l-3 4Zm5-10 3-4 3 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectProviderModal({
|
||||||
|
provider,
|
||||||
|
label,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onContinue,
|
||||||
|
}: {
|
||||||
|
provider: MarketplaceProvider['provider'];
|
||||||
|
label: string;
|
||||||
|
loading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onContinue: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={`${label} verbinden`}
|
||||||
|
onClose={onClose}
|
||||||
|
hideHeader
|
||||||
|
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
|
||||||
|
>
|
||||||
|
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dialog schließen"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 pb-5 pt-2">
|
||||||
|
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||||
|
<Icon name="modules" className="h-9 w-9" />
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5" aria-hidden="true">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||||
|
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||||
|
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||||
|
</span>
|
||||||
|
<span className="flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||||
|
<ProviderMark provider={provider} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">{label} verbinden</h2>
|
||||||
|
<p className="mt-1 text-base text-slate-500">MPM möchte dein {label}-Konto mit dem Marketplace verbinden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-2xl border border-slate-200 px-5 py-4 text-sm">
|
||||||
|
<section>
|
||||||
|
<h3 className="font-medium text-slate-900">Berechtigungen werden respektiert</h3>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section className="border-t border-slate-200 pt-4">
|
||||||
|
<h3 className="font-medium text-slate-900">Du behältst die Kontrolle</h3>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section className="border-t border-slate-200 pt-4">
|
||||||
|
<h3 className="font-medium text-slate-900">Mit dieser App geteilte Daten</h3>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="mt-6 h-11 w-full rounded-full" loading={loading} onClick={onContinue}>
|
||||||
|
Weiter zu {label} <span aria-hidden="true">↗</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { apiRequest, ApiError } from '../../lib/api-client';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
|
||||||
import { Input } from '../../components/ui/input';
|
|
||||||
import { Button } from '../../components/ui/button';
|
|
||||||
import { useToast } from '../../components/ui/toast';
|
|
||||||
import { ErrorState, Spinner } from '../../components/ui/states';
|
|
||||||
|
|
||||||
/** Einstellung (API-Vertrag /api/v1/settings). */
|
|
||||||
const settingSchema = z.object({
|
|
||||||
key: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
updatedAt: z.string(),
|
|
||||||
updatedBy: z.string().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsResponseSchema = z.object({
|
|
||||||
settings: z.array(settingSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Anzeige-Namen für Einstellungs-Schlüssel. */
|
|
||||||
const SETTING_LABELS: Record<string, string> = {
|
|
||||||
'platform.name': 'Plattform-Name',
|
|
||||||
'platform.description': 'Beschreibung',
|
|
||||||
'platform.maintenance_mode': 'Wartungsmodus (true/false)',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Systemeinstellungen-Seite (nur Admin). */
|
|
||||||
export function SettingsPage(): ReactNode {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const settingsQuery = useQuery({
|
|
||||||
queryKey: ['settings'],
|
|
||||||
queryFn: async () => settingsResponseSchema.parse(await apiRequest('/api/v1/settings')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: async (input: { key: string; value: string }) =>
|
|
||||||
apiRequest<{ setting: unknown }>(`/api/v1/settings/${input.key}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { value: input.value },
|
|
||||||
}),
|
|
||||||
onSuccess: (_result, variables) => {
|
|
||||||
showToast('success', `Einstellung "${variables.key}" gespeichert`);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
showToast('error', error instanceof ApiError ? error.message : 'Speichern fehlgeschlagen');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>, key: string): void {
|
|
||||||
event.preventDefault();
|
|
||||||
const value = editValues[key];
|
|
||||||
if (value !== undefined && value.trim().length > 0) {
|
|
||||||
updateMutation.mutate({ key, value: value.trim() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Einstellungen</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Zentrale Konfiguration der Plattform.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="Plattform-Einstellungen" />
|
|
||||||
<CardBody>
|
|
||||||
{settingsQuery.isLoading && <Spinner label="Einstellungen werden geladen…" />}
|
|
||||||
{settingsQuery.isError && (
|
|
||||||
<ErrorState message="Einstellungen konnten nicht geladen werden." />
|
|
||||||
)}
|
|
||||||
{settingsQuery.data && settingsQuery.data.settings.length === 0 && (
|
|
||||||
<p className="py-4 text-center text-sm text-slate-500">
|
|
||||||
Noch keine Einstellungen vorhanden. Änderungen legen sie automatisch an.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{settingsQuery.data && settingsQuery.data.settings.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{settingsQuery.data.settings.map((setting) => (
|
|
||||||
<form
|
|
||||||
key={setting.key}
|
|
||||||
onSubmit={(event) => handleSubmit(event, setting.key)}
|
|
||||||
className="flex items-end gap-3"
|
|
||||||
noValidate
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
label={SETTING_LABELS[setting.key] ?? setting.key}
|
|
||||||
value={editValues[setting.key] ?? setting.value}
|
|
||||||
onChange={(event) =>
|
|
||||||
setEditValues((current) => ({
|
|
||||||
...current,
|
|
||||||
[setting.key]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Zuletzt geändert von {setting.updatedBy ?? '–'} am{' '}
|
|
||||||
{new Date(setting.updatedAt).toLocaleString('de-DE')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="secondary"
|
|
||||||
loading={
|
|
||||||
updateMutation.isPending && updateMutation.variables?.key === setting.key
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,10 +45,10 @@ export function SystemStatusPage(): ReactNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Systemstatus</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-2 text-base text-slate-500">
|
||||||
Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).
|
Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '../../components/ui/button';
|
|
||||||
import { Modal } from '../../components/ui/modal';
|
import { Modal } from '../../components/ui/modal';
|
||||||
import { useToast } from '../../components/ui/toast';
|
import { useToast } from '../../components/ui/toast';
|
||||||
import { ApiError } from '../../lib/api-client';
|
import { ApiError } from '../../lib/api-client';
|
||||||
@@ -14,7 +13,7 @@ import type { Module, User } from '../../lib/schemas';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog: Modul-Berechtigungen eines Benutzers verwalten.
|
* Dialog: Modul-Berechtigungen eines Benutzers verwalten.
|
||||||
* Zeigt alle installierten Module mit GRANTED/DENIED-Schaltern.
|
* Zeigt alle installierten Module mit Freigabe-Checkboxen.
|
||||||
*/
|
*/
|
||||||
export function UserPermissionsModal({
|
export function UserPermissionsModal({
|
||||||
user,
|
user,
|
||||||
@@ -84,7 +83,11 @@ export function UserPermissionsModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
title="Modul-Berechtigungen"
|
title="Modul-Berechtigungen"
|
||||||
description={`Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`}
|
description={
|
||||||
|
user.role === 'ADMIN'
|
||||||
|
? 'Administratoren haben automatisch Zugriff auf alle aktivierten Module.'
|
||||||
|
: `Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`
|
||||||
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{modulesQuery.isLoading || permissionsQuery.isLoading ? (
|
{modulesQuery.isLoading || permissionsQuery.isLoading ? (
|
||||||
@@ -98,35 +101,46 @@ export function UserPermissionsModal({
|
|||||||
Noch keine Module installiert.
|
Noch keine Module installiert.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Modul</th>
|
||||||
|
<th scope="col" className="w-32 px-4 py-3 text-center font-semibold">Freigeben</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{modulesQuery.data?.map((module) => {
|
{modulesQuery.data?.map((module) => {
|
||||||
const isGranted = grantedModuleIds.has(module.id);
|
const isAdmin = user.role === 'ADMIN';
|
||||||
|
const isGranted = isAdmin || grantedModuleIds.has(module.id);
|
||||||
|
const isSaving =
|
||||||
|
toggleMutation.isPending && toggleMutation.variables?.module.id === module.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||||
key={module.id}
|
<td className="px-4 py-3">
|
||||||
className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3"
|
<p className="font-medium text-slate-900">{module.name}</p>
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-900">{module.name}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
/{module.slug} · Version {module.version}
|
/{module.slug} · Version {module.version}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</td>
|
||||||
<Button
|
<td className="px-4 py-3 text-center">
|
||||||
size="sm"
|
<input
|
||||||
variant={isGranted ? 'danger' : 'primary'}
|
type="checkbox"
|
||||||
loading={
|
checked={isGranted}
|
||||||
toggleMutation.isPending &&
|
disabled={isAdmin || isSaving}
|
||||||
toggleMutation.variables?.module.id === module.id
|
aria-label={`${module.name} für ${user.displayName} freigeben`}
|
||||||
}
|
title={isAdmin ? 'Administratoren haben automatisch Zugriff' : undefined}
|
||||||
onClick={() => handleToggle(module)}
|
className="h-4 w-4 cursor-pointer accent-brand-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
onChange={() => handleToggle(module)}
|
||||||
{isGranted ? 'Entziehen' : 'Freigeben'}
|
/>
|
||||||
</Button>
|
</td>
|
||||||
</li>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -49,10 +49,10 @@ function fieldErrorsFromApi(details: Record<string, string | string[]> | undefin
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Datumsformat für Tabellenanzeigen. */
|
/** Datumsformat für Tabellenanzeigen. */
|
||||||
function formatDate(isoDate: string | null): string {
|
function formatDate(isoDate: string | null): string {
|
||||||
if (!isoDate) {
|
if (!isoDate) {
|
||||||
return '–';
|
return '–';
|
||||||
}
|
}
|
||||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -118,7 +118,7 @@ function CreateUserModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title="Benutzer anlegen"
|
title="Benutzer anlegen"
|
||||||
description="Neuen Benutzer für die Plattform registrieren"
|
description="Neuen Benutzer für die Plattform registrieren"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
@@ -244,7 +244,7 @@ function EditUserModal({ user, onClose }: { user: User; onClose: () => void }):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formular: Passwort zurücksetzen. */
|
/** Formular: Passwort zurücksetzen. */
|
||||||
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
@@ -253,7 +253,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
|||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
|
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
|
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -261,7 +261,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
|||||||
setFormError(error.message);
|
setFormError(error.message);
|
||||||
setFieldErrors(fieldErrorsFromApi(error.details));
|
setFieldErrors(fieldErrorsFromApi(error.details));
|
||||||
} else {
|
} else {
|
||||||
setFormError('Passwort konnte nicht zurückgesetzt werden');
|
setFormError('Passwort konnte nicht zurückgesetzt werden');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -285,8 +285,8 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
title="Passwort zurücksetzen"
|
title="Passwort zurücksetzen"
|
||||||
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
|
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
@@ -308,7 +308,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
|
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
|
||||||
Zurücksetzen
|
Zurücksetzen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -316,7 +316,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bestätigungsdialog: Benutzer löschen. */
|
/** Bestätigungsdialog: Benutzer löschen. */
|
||||||
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -326,19 +326,19 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void })
|
|||||||
mutationFn: () => deleteUser(user.id),
|
mutationFn: () => deleteUser(user.id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
|
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
|
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
title="Benutzer löschen"
|
title="Benutzer löschen"
|
||||||
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
|
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
{formError && (
|
{formError && (
|
||||||
@@ -355,14 +355,14 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void })
|
|||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
onClick={() => deleteMutation.mutate()}
|
onClick={() => deleteMutation.mutate()}
|
||||||
>
|
>
|
||||||
Endgültig löschen
|
Endgültig löschen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
|
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
|
||||||
export function UsersPage(): ReactNode {
|
export function UsersPage(): ReactNode {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -390,11 +390,11 @@ export function UsersPage(): ReactNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-2 text-base text-slate-500">
|
||||||
Benutzer anlegen, bearbeiten und verwalten.
|
Benutzer anlegen, bearbeiten und verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +416,7 @@ export function UsersPage(): ReactNode {
|
|||||||
{usersQuery.isLoading && (
|
{usersQuery.isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||||
Benutzer werden geladen…
|
Benutzer werden geladen…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -431,9 +431,7 @@ export function UsersPage(): ReactNode {
|
|||||||
<tr key={user.id} className="border-b border-slate-100 last:border-0">
|
<tr key={user.id} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium text-slate-900">{user.displayName}</div>
|
<div className="font-medium text-slate-900">{user.displayName}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">@{user.username}</div>
|
||||||
@{user.username} · {user.email}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
@@ -444,7 +442,7 @@ export function UsersPage(): ReactNode {
|
|||||||
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
{user.role === 'ADMIN' ? 'Admin' : 'Benutzer'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -485,7 +483,7 @@ export function UsersPage(): ReactNode {
|
|||||||
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
|
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
|
||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
|
import { ApiError, apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
|
||||||
import { authUserSchema, type AuthUser } from '../../lib/schemas';
|
import { authUserSchema, type AuthUser } from '../../lib/schemas';
|
||||||
|
|
||||||
/** Zustand des Auth-Contexts. */
|
/** Zustand des Auth-Contexts. */
|
||||||
@@ -48,9 +48,26 @@ export function AuthProvider({ children }: { children: ReactNode }): ReactNode {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let sessionCheck: Promise<void> | null = null;
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
|
if (sessionCheck) return;
|
||||||
|
|
||||||
|
sessionCheck = apiRequest<{ user: unknown }>('/api/v1/auth/me')
|
||||||
|
.then((response) => {
|
||||||
|
const currentUser = authUserSchema.parse(response.user);
|
||||||
|
setUser(currentUser);
|
||||||
|
setStatus('authenticated');
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setStatus('unauthenticated');
|
setStatus('unauthenticated');
|
||||||
|
}
|
||||||
|
// Network errors and server errors do not prove the session expired.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sessionCheck = null;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return () => setUnauthorizedHandler(() => undefined);
|
return () => setUnauthorizedHandler(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { healthSchema, type Health } from '../../lib/schemas';
|
|||||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
|
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
|
||||||
|
import { Icon } from '../../components/ui/icon';
|
||||||
|
|
||||||
/** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */
|
/** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */
|
||||||
export function DashboardPage(): ReactNode {
|
export function DashboardPage(): ReactNode {
|
||||||
@@ -28,12 +29,12 @@ export function DashboardPage(): ReactNode {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">
|
<h1 className="text-3xl font-bold text-slate-900">
|
||||||
Willkommen, {user?.displayName} 👋
|
Willkommen, {user?.displayName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-2 text-base text-slate-500">
|
||||||
Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.
|
Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +57,7 @@ export function DashboardPage(): ReactNode {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
title="Noch keine Module freigegeben"
|
title="Noch keine Module freigegeben"
|
||||||
description="Sobald der Administrator Ihnen Module zugewiesen hat, erscheinen diese hier als Kacheln."
|
description="Sobald der Administrator Ihnen Module zugewiesen hat, erscheinen diese hier als Kacheln."
|
||||||
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
|
icon={<Icon name="modules" className="h-8 w-8 text-slate-500" />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modulesQuery.data && modulesQuery.data.length > 0 && (
|
{modulesQuery.data && modulesQuery.data.length > 0 && (
|
||||||
@@ -65,10 +66,10 @@ export function DashboardPage(): ReactNode {
|
|||||||
<a
|
<a
|
||||||
key={module.id}
|
key={module.id}
|
||||||
href={`/${module.slug}`}
|
href={`/${module.slug}`}
|
||||||
className="group rounded-xl border border-slate-200 p-5 transition-colors hover:border-brand-300 hover:bg-brand-50/50"
|
className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<span className="text-2xl" aria-hidden="true">🧩</span>
|
<Icon name="modules" className="h-6 w-6 text-slate-500" />
|
||||||
<Badge variant={module.status === 'RUNNING' ? 'success' : 'neutral'}>
|
<Badge variant={module.status === 'RUNNING' ? 'success' : 'neutral'}>
|
||||||
{module.status === 'RUNNING' ? 'Verfügbar' : module.status}
|
{module.status === 'RUNNING' ? 'Verfügbar' : module.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -77,11 +78,11 @@ export function DashboardPage(): ReactNode {
|
|||||||
{module.name}
|
{module.name}
|
||||||
</h3>
|
</h3>
|
||||||
{module.description && (
|
{module.description && (
|
||||||
<p className="mt-1 text-sm text-slate-500">{module.description}</p>
|
<p className="mt-2 text-base text-slate-500">{module.description}</p>
|
||||||
)}
|
)}
|
||||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-600">
|
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-600">
|
||||||
Öffnen
|
Öffnen
|
||||||
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
|
<Icon name="arrow" className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,36 +3,26 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Modal } from '../../components/ui/modal';
|
||||||
import { useToast } from '../../components/ui/toast';
|
import { useToast } from '../../components/ui/toast';
|
||||||
import { ApiError } from '../../lib/api-client';
|
import { ApiError } from '../../lib/api-client';
|
||||||
import { changePassword, fetchProfile } from '../../lib/users-api';
|
import { changePassword, fetchProfile } from '../../lib/users-api';
|
||||||
import { changePasswordSchema } from '../../lib/schemas';
|
import { changePasswordSchema } from '../../lib/schemas';
|
||||||
|
|
||||||
/** Datumsformat für Profilanzeigen. */
|
|
||||||
function formatDate(isoDate: string | null): string {
|
function formatDate(isoDate: string | null): string {
|
||||||
if (!isoDate) {
|
if (!isoDate) return '–';
|
||||||
return '–';
|
|
||||||
}
|
|
||||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||||
day: '2-digit',
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Profil des angemeldeten Benutzers mit Passwortänderung. */
|
/** Profilinformationen und Passwortoptionen im Dialog am Benutzernamen. */
|
||||||
export function ProfilePage(): ReactNode {
|
export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({ queryKey: ['profile'], queryFn: fetchProfile });
|
||||||
queryKey: ['profile'],
|
|
||||||
queryFn: fetchProfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
const changePasswordMutation = useMutation({
|
const changePasswordMutation = useMutation({
|
||||||
mutationFn: changePassword,
|
mutationFn: changePassword,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -43,9 +33,8 @@ export function ProfilePage(): ReactNode {
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
setFormError(error.message);
|
setFormError(error.message);
|
||||||
const details = error.details ?? {};
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(details)) {
|
for (const [key, value] of Object.entries(error.details ?? {})) {
|
||||||
errors[key] = Array.isArray(value) ? value[0] : value;
|
errors[key] = Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
@@ -59,7 +48,6 @@ export function ProfilePage(): ReactNode {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const parsed = changePasswordSchema.safeParse({
|
const parsed = changePasswordSchema.safeParse({
|
||||||
currentPassword: formData.get('currentPassword'),
|
currentPassword: formData.get('currentPassword'),
|
||||||
@@ -70,9 +58,7 @@ export function ProfilePage(): ReactNode {
|
|||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const issue of parsed.error.issues) {
|
for (const issue of parsed.error.issues) {
|
||||||
const key = String(issue.path[0] ?? 'form');
|
const key = String(issue.path[0] ?? 'form');
|
||||||
if (!errors[key]) {
|
if (!errors[key]) errors[key] = issue.message;
|
||||||
errors[key] = issue.message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
return;
|
return;
|
||||||
@@ -80,105 +66,58 @@ export function ProfilePage(): ReactNode {
|
|||||||
changePasswordMutation.mutate(parsed.data);
|
changePasswordMutation.mutate(parsed.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = profileQuery.data;
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6">
|
<Modal
|
||||||
<div>
|
open
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Mein Profil</h1>
|
title="Profileinstellungen"
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
description="Kontodetails und Passwort verwalten."
|
||||||
Ihre persönlichen Daten und Passwort-Einstellungen.
|
onClose={onClose}
|
||||||
</p>
|
panelClassName="max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||||
</div>
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Persönliche Daten" />
|
<CardHeader title="Persönliche Daten" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
|
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
|
||||||
{profileQuery.isError && (
|
{profileQuery.isError && <p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>}
|
||||||
<p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>
|
{profile && (
|
||||||
)}
|
|
||||||
{profileQuery.data && (
|
|
||||||
<dl className="grid gap-4 sm:grid-cols-2">
|
<dl className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<ProfileField label="Anzeigename" value={profile.displayName} />
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Anzeigename</dt>
|
<ProfileField label="Benutzername" value={`@${profile.username}`} />
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
<ProfileField label="E-Mail" value={profile.email} />
|
||||||
{profileQuery.data.displayName}
|
<ProfileField label="Rolle" value={profile.role === 'ADMIN' ? 'Administrator' : 'Benutzer'} />
|
||||||
</dd>
|
<ProfileField label="Letzter Login" value={formatDate(profile.lastLoginAt)} />
|
||||||
</div>
|
<ProfileField label="Mitglied seit" value={formatDate(profile.createdAt)} />
|
||||||
<div>
|
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Benutzername</dt>
|
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
@{profileQuery.data.username}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">E-Mail</dt>
|
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{profileQuery.data.email}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Rolle</dt>
|
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{profileQuery.data.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Letzter Login</dt>
|
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{formatDate(profileQuery.data.lastLoginAt)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Mitglied seit</dt>
|
|
||||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{formatDate(profileQuery.data.createdAt)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader title="Passwort ändern" description="Andere aktive Sitzungen werden danach abgemeldet." />
|
||||||
title="Passwort ändern"
|
|
||||||
description="Nach der Änderung werden alle anderen Sitzungen abgemeldet."
|
|
||||||
/>
|
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleSubmit} className="max-w-sm space-y-4" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||||
<Input
|
<Input label="Aktuelles Passwort" name="currentPassword" type="password" autoComplete="current-password" error={fieldErrors.currentPassword} />
|
||||||
label="Aktuelles Passwort"
|
<Input label="Neues Passwort" name="newPassword" type="password" autoComplete="new-password" hint="Mindestens 10 Zeichen" error={fieldErrors.newPassword} />
|
||||||
name="currentPassword"
|
<Input label="Neues Passwort bestätigen" name="confirmPassword" type="password" autoComplete="new-password" error={fieldErrors.confirmPassword} />
|
||||||
type="password"
|
{formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
|
||||||
autoComplete="current-password"
|
<div className="flex justify-end">
|
||||||
error={fieldErrors.currentPassword}
|
<Button type="submit" loading={changePasswordMutation.isPending}>Passwort ändern</Button>
|
||||||
/>
|
</div>
|
||||||
<Input
|
|
||||||
label="Neues Passwort"
|
|
||||||
name="newPassword"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
hint="Mindestens 10 Zeichen"
|
|
||||||
error={fieldErrors.newPassword}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Neues Passwort bestätigen"
|
|
||||||
name="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
error={fieldErrors.confirmPassword}
|
|
||||||
/>
|
|
||||||
{formError && (
|
|
||||||
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button type="submit" loading={changePasswordMutation.isPending}>
|
|
||||||
Passwort ändern
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileField({ label, value }: { label: string; value: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-slate-900">{value}</dd>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,3 +26,53 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Graustufen-Dunkelmodus: Oberflächen bleiben anthrazit statt tiefschwarz,
|
||||||
|
Markenakzente werden neutral statt blau. */
|
||||||
|
html[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-slate-50: #272a2d;
|
||||||
|
--color-slate-100: #3c4044;
|
||||||
|
--color-slate-200: #474b50;
|
||||||
|
--color-slate-300: #565b61;
|
||||||
|
--color-slate-400: #92979d;
|
||||||
|
--color-slate-500: #aeb3b9;
|
||||||
|
--color-slate-600: #c2c6cb;
|
||||||
|
--color-slate-700: #d6d9dd;
|
||||||
|
--color-slate-800: #e5e7eb;
|
||||||
|
--color-slate-900: #f3f4f6;
|
||||||
|
background: #272a2d;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] body {
|
||||||
|
background: #272a2d;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .bg-white {
|
||||||
|
background-color: #303337;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] input,
|
||||||
|
html[data-theme='dark'] textarea,
|
||||||
|
html[data-theme='dark'] select {
|
||||||
|
background-color: #383b3f;
|
||||||
|
color: #f1f2f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .bg-slate-900\/50 {
|
||||||
|
background-color: rgb(70 74 79 / 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .shadow-sm,
|
||||||
|
html[data-theme='dark'] .shadow-xl {
|
||||||
|
--tw-shadow: 0 8px 24px rgb(109 114 120 / 12%);
|
||||||
|
box-shadow: var(--tw-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .mpm-module-tile:hover {
|
||||||
|
border-color: #686d73;
|
||||||
|
background-color: #3b3f43;
|
||||||
|
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
||||||
function readCsrfToken(): string | null {
|
function readCsrfToken(): string | null {
|
||||||
|
let csrfToken: string | null = null;
|
||||||
for (const part of document.cookie.split(';')) {
|
for (const part of document.cookie.split(';')) {
|
||||||
const [name, ...value] = part.trim().split('=');
|
const [name, ...value] = part.trim().split('=');
|
||||||
if (name === 'mpm_csrf') {
|
if (name === 'mpm_csrf') {
|
||||||
return decodeURIComponent(value.join('='));
|
csrfToken = decodeURIComponent(value.join('='));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
|
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
|
||||||
@@ -63,11 +64,14 @@ export async function apiRequest<TResponse>(
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store',
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 && unauthorizedHandler) {
|
// A 401 from a feature endpoint may be request-specific. Let the auth
|
||||||
|
// context verify the session through /auth/me before clearing the user.
|
||||||
|
if (response.status === 401 && path !== '/api/v1/auth/me' && unauthorizedHandler) {
|
||||||
unauthorizedHandler();
|
unauthorizedHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,63 @@ export async function revokeModuleAccess(userId: string, moduleId: string): Prom
|
|||||||
|
|
||||||
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
||||||
function readCsrfToken(): string | null {
|
function readCsrfToken(): string | null {
|
||||||
|
let csrfToken: string | null = null;
|
||||||
for (const part of document.cookie.split(';')) {
|
for (const part of document.cookie.split(';')) {
|
||||||
const [name, ...value] = part.trim().split('=');
|
const [name, ...value] = part.trim().split('=');
|
||||||
if (name === 'mpm_csrf') {
|
if (name === 'mpm_csrf') {
|
||||||
return decodeURIComponent(value.join('='));
|
csrfToken = decodeURIComponent(value.join('='));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceProvider {
|
||||||
|
provider: 'github' | 'gitea' | 'forgejo';
|
||||||
|
label: string;
|
||||||
|
configured: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
accountLogin: string | null;
|
||||||
|
baseUrl: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceRepository {
|
||||||
|
owner: string;
|
||||||
|
repository: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
description: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketplaceRepositories(provider: MarketplaceProvider['provider']): Promise<MarketplaceRepository[]> {
|
||||||
|
return apiRequest<MarketplaceRepository[]>(`/api/v1/marketplace/repositories/${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installMarketplaceRepository(
|
||||||
|
provider: MarketplaceProvider['provider'],
|
||||||
|
owner: string,
|
||||||
|
repository: string,
|
||||||
|
): Promise<Module> {
|
||||||
|
const response = await apiRequest<{ module: unknown }>(
|
||||||
|
`/api/v1/marketplace/repositories/${provider}/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/install`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return moduleSchema.parse(response.module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketplaceProviders(): Promise<MarketplaceProvider[]> {
|
||||||
|
return apiRequest<MarketplaceProvider[]>('/api/v1/marketplace/providers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginMarketplaceConnection(provider: MarketplaceProvider['provider']): Promise<string> {
|
||||||
|
const response = await apiRequest<{ authorizationUrl: string }>(
|
||||||
|
`/api/v1/marketplace/connections/${provider}/start`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return response.authorizationUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectMarketplaceProvider(provider: MarketplaceProvider['provider']): Promise<void> {
|
||||||
|
await apiRequest(`/api/v1/marketplace/connections/${provider}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,12 @@ import { SystemStatusPage } from './features/admin/system-status-page';
|
|||||||
import { UsersPage } from './features/admin/users-page';
|
import { UsersPage } from './features/admin/users-page';
|
||||||
import { ModulesPage } from './features/admin/modules-page';
|
import { ModulesPage } from './features/admin/modules-page';
|
||||||
import { AuditPage } from './features/admin/audit-page';
|
import { AuditPage } from './features/admin/audit-page';
|
||||||
import { SettingsPage } from './features/admin/settings-page';
|
|
||||||
import { ProfilePage } from './features/profile/profile-page';
|
|
||||||
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme =
|
||||||
|
localStorage.getItem('mpm-theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
/** TanStack Query: keine automatischen Refetches bei Fensterfokus. */
|
/** TanStack Query: keine automatischen Refetches bei Fensterfokus. */
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -36,7 +37,6 @@ function AppRoutes(): ReactNode {
|
|||||||
|
|
||||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
||||||
@@ -53,10 +53,6 @@ function AppRoutes(): ReactNode {
|
|||||||
path="/admin/audit"
|
path="/admin/audit"
|
||||||
element={<RequireAdmin><AuditPage /></RequireAdmin>}
|
element={<RequireAdmin><AuditPage /></RequireAdmin>}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/admin/settings"
|
|
||||||
element={<RequireAdmin><SettingsPage /></RequireAdmin>}
|
|
||||||
/>
|
|
||||||
<Route path="/403" element={<ForbiddenPage />} />
|
<Route path="/403" element={<ForbiddenPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -37,16 +37,43 @@ services:
|
|||||||
PORT: ${PORT:-3000}
|
PORT: ${PORT:-3000}
|
||||||
DATABASE_URL: ${DATABASE_URL:?Bitte DATABASE_URL in .env setzen}
|
DATABASE_URL: ${DATABASE_URL:?Bitte DATABASE_URL in .env setzen}
|
||||||
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-120}
|
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-120}
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||||
BEHIND_PROXY: "true"
|
BEHIND_PROXY: "true"
|
||||||
|
MODULE_UID_BASE: ${MODULE_UID_BASE:-20000}
|
||||||
|
MODULE_DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
MPM_CONTAINER_NAME: mpm-platform
|
||||||
|
DOCKER_SOCKET_GID: ${DOCKER_SOCKET_GID:-0}
|
||||||
MODULES_DIR: /app/data/modules
|
MODULES_DIR: /app/data/modules
|
||||||
LOGS_DIR: /app/data/logs
|
LOGS_DIR: /app/data/logs
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
|
||||||
|
MARKETPLACE_PUBLIC_URL: ${MARKETPLACE_PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-8080}}
|
||||||
|
MARKETPLACE_TOKEN_ENCRYPTION_KEY: ${MARKETPLACE_TOKEN_ENCRYPTION_KEY:-}
|
||||||
|
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
|
||||||
|
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
|
||||||
|
GITEA_OAUTH_CLIENT_ID: ${GITEA_OAUTH_CLIENT_ID:-}
|
||||||
|
GITEA_OAUTH_CLIENT_SECRET: ${GITEA_OAUTH_CLIENT_SECRET:-}
|
||||||
|
FORGEJO_BASE_URL: ${FORGEJO_BASE_URL:-}
|
||||||
|
FORGEJO_OAUTH_CLIENT_ID: ${FORGEJO_OAUTH_CLIENT_ID:-}
|
||||||
|
FORGEJO_OAUTH_CLIENT_SECRET: ${FORGEJO_OAUTH_CLIENT_SECRET:-}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- SETUID
|
||||||
|
- SETGID
|
||||||
|
- KILL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_SOCKET_GID:-0}"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Needed by the backend to manage isolated per-module Compose stacks.
|
||||||
|
# Module containers never receive this socket.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# Modul-Installationen und Logs persistent halten (Container-Updates überleben)
|
# Modul-Installationen und Logs persistent halten (Container-Updates überleben)
|
||||||
- modules-data:/app/data/modules
|
- modules-data:/app/data/modules
|
||||||
- module-logs:/app/data/logs
|
- module-logs:/app/data/logs
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# =============================================================
|
# =============================================================
|
||||||
# MPM – Reverse Proxy (Nginx)
|
# MPM – Reverse Proxy (Nginx)
|
||||||
# Läuft als unprivilegierter Benutzer "app" auf Port 8080.
|
# Master has restricted capabilities; workers run as unprivileged user app.
|
||||||
# - / -> Management-Frontend (SPA, statische Dateien)
|
# - / -> Management-Frontend (SPA, statische Dateien)
|
||||||
# - /api/ -> Management-Backend (127.0.0.1:3000)
|
# - /api/ -> Management-Backend (127.0.0.1:3000)
|
||||||
# Ab Phase 4 werden hier dynamisch Modul-Routen (/slug) ergänzt.
|
# Ab Phase 4 werden hier dynamisch Modul-Routen (/slug) ergänzt.
|
||||||
# =============================================================
|
# =============================================================
|
||||||
|
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
|
user app;
|
||||||
pid /tmp/nginx.pid;
|
pid /tmp/nginx.pid;
|
||||||
error_log /dev/stderr warn;
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
; - platform-backend : NestJS Management-API (127.0.0.1:3000)
|
; - platform-backend : NestJS Management-API (127.0.0.1:3000)
|
||||||
; - nginx : Reverse Proxy (0.0.0.0:8080)
|
; - nginx : Reverse Proxy (0.0.0.0:8080)
|
||||||
; Ab Phase 3 werden hier die Modul-Prozesse registriert.
|
; Ab Phase 3 werden hier die Modul-Prozesse registriert.
|
||||||
; Läuft als unprivilegierter Benutzer "app".
|
; Supervisor has only SETUID/SETGID/KILL; backend and Nginx workers run as app.
|
||||||
; =============================================================
|
; =============================================================
|
||||||
|
|
||||||
[supervisord]
|
[supervisord]
|
||||||
@@ -13,9 +13,10 @@ logfile=/dev/null
|
|||||||
logfile_maxbytes=0
|
logfile_maxbytes=0
|
||||||
pidfile=/tmp/supervisord.pid
|
pidfile=/tmp/supervisord.pid
|
||||||
childlogdir=/tmp
|
childlogdir=/tmp
|
||||||
|
user=root
|
||||||
|
|
||||||
[program:platform-backend]
|
[program:platform-backend]
|
||||||
command=node dist/main.js
|
command=/usr/bin/setpriv --reuid=app --regid=app --groups=%(ENV_DOCKER_SOCKET_GID)s --inh-caps=+setuid,+setgid,+kill --ambient-caps=+setuid,+setgid,+kill -- node dist/main.js
|
||||||
directory=/app/platform-backend
|
directory=/app/platform-backend
|
||||||
autorestart=true
|
autorestart=true
|
||||||
startretries=10
|
startretries=10
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Die Plattform darf **niemals** von einem einzelnen Modul abhängig sein. Wird ei
|
|||||||
Internet
|
Internet
|
||||||
│
|
│
|
||||||
▼ HTTPS (Produktion; lokal :8080)
|
▼ HTTPS (Produktion; lokal :8080)
|
||||||
Docker Container "mpm-platform" (Benutzer: app, kein Root)
|
Docker Container "mpm-platform" (Supervisor mit engen Capabilities)
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ Supervisor (Prozessmanager, Crash-Recovery) │
|
│ Supervisor (Prozessmanager, Crash-Recovery) │
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -40,10 +40,13 @@ persistentes Volume "postgres-data")
|
|||||||
|
|
||||||
Grundsätze:
|
Grundsätze:
|
||||||
|
|
||||||
- **Ein** Applikationscontainer; Module laufen als interne Prozesse darin (kein Docker-in-Docker, kein Docker-Socket).
|
- MPM bleibt der Managementcontainer. Neue Module laufen in eigenen Compose-Stacks mit optionalen Datenbankservices.
|
||||||
|
- Nur der MPM-Backendprozess erhält Zugriff auf den Docker-Socket; Modulcontainer erhalten ihn nie.
|
||||||
|
- Docker-Socket-Zugriff entspricht weitreichender Kontrolle über den Docker-Host. Installationsrechte müssen deshalb vertrauenswürdigen Administratoren vorbehalten bleiben.
|
||||||
- Interne Ports (z. B. 3000, 41001+) werden **nie** nach außen veröffentlicht; nur Nginx lauscht auf 8080.
|
- Interne Ports (z. B. 3000, 41001+) werden **nie** nach außen veröffentlicht; nur Nginx lauscht auf 8080.
|
||||||
- PostgreSQL liegt außerhalb des Applikationscontainers → Container-Neustarts/Updates zerstören keine Nutzdaten.
|
- PostgreSQL liegt außerhalb des Applikationscontainers → Container-Neustarts/Updates zerstören keine Nutzdaten.
|
||||||
- Alle Prozesse laufen als unprivilegierter Benutzer `app`.
|
- Supervisor und Nginx-Master erhalten nur die Container-Capabilities `SETUID`, `SETGID` und `KILL`.
|
||||||
|
- Backend und Nginx-Worker laufen als `app`; Modulservices werden mit `no-new-privileges` gestartet und intern über ein eigenes Gateway-Netz geroutet.
|
||||||
|
|
||||||
## 3. Prozessmodell
|
## 3. Prozessmodell
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ Stürzt ein Modulprozess ab, startet Supervisor ihn automatisch neu – die Mana
|
|||||||
|
|
||||||
```
|
```
|
||||||
Browser ──POST /api/v1/auth/login──▶ Nginx ──▶ NestJS
|
Browser ──POST /api/v1/auth/login──▶ Nginx ──▶ NestJS
|
||||||
│ Validierung (Zod) + Rate Limit + Lockout + Argon2id
|
│ Validierung (Zod) + IP-Rate-Limit + Argon2id
|
||||||
│ Session in PostgreSQL anlegen (Token nur gehasht gespeichert)
|
│ Session in PostgreSQL anlegen (Token nur gehasht gespeichert)
|
||||||
◀── Set-Cookie: mpm_session (HttpOnly, SameSite=Lax)
|
◀── Set-Cookie: mpm_session (HttpOnly, SameSite=Lax)
|
||||||
Set-Cookie: mpm_csrf (lesbar für CSRF-Doppel-Submit)
|
Set-Cookie: mpm_csrf (lesbar für CSRF-Doppel-Submit)
|
||||||
@@ -97,7 +100,7 @@ Alle Plattform-Tabellen liegen im Standard-Schema (ab Phase 3 Auslagerung in ein
|
|||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `roles` | Globale Rollen: `ADMIN`, `USER` |
|
| `roles` | Globale Rollen: `ADMIN`, `USER` |
|
||||||
| `users` | Benutzer mit Argon2id-Hash, Rollen-FK, Lockout-Feldern |
|
| `users` | Benutzer mit Argon2id-Hash, Rollen-FK und Fehlversuchs-Zähler |
|
||||||
| `sessions` | Serverseitige Sessions (Token SHA-256-gehasht, CSRF-Token, Gleitende Verlängerung) |
|
| `sessions` | Serverseitige Sessions (Token SHA-256-gehasht, CSRF-Token, Gleitende Verlängerung) |
|
||||||
| `audit_logs` | Zentrales Audit (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, …) |
|
| `audit_logs` | Zentrales Audit (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, …) |
|
||||||
| `schema_migrations` | Angewandte Migrationen (eigener Runner mit Advisory-Lock) |
|
| `schema_migrations` | Angewandte Migrationen (eigener Runner mit Advisory-Lock) |
|
||||||
@@ -110,7 +113,7 @@ Geplant (Phase 3+): `modules`, `user_module_permissions`, `module_settings`, `sy
|
|||||||
- **Serverseitige Sessions**: 256-Bit-Zufalls-Token im `HttpOnly`-Cookie; in der DB wird nur der SHA-256-Hash gespeichert. `Secure` (produktiv), `SameSite=Lax`, kurze Laufzeit (`SESSION_TTL_MINUTES`), gleitende Verlängerung.
|
- **Serverseitige Sessions**: 256-Bit-Zufalls-Token im `HttpOnly`-Cookie; in der DB wird nur der SHA-256-Hash gespeichert. `Secure` (produktiv), `SameSite=Lax`, kurze Laufzeit (`SESSION_TTL_MINUTES`), gleitende Verlängerung.
|
||||||
- **CSRF-Schutz**: Doppel-Submit – Server speichert pro Session ein CSRF-Token; bei jedem zustandsändernden Request muss der Header `X-CSRF-Token` (konstantzeitvergleich) übereinstimmen.
|
- **CSRF-Schutz**: Doppel-Submit – Server speichert pro Session ein CSRF-Token; bei jedem zustandsändernden Request muss der Header `X-CSRF-Token` (konstantzeitvergleich) übereinstimmen.
|
||||||
- **Rate Limiting**: In-Memory-Fenster pro IP für Login (Standard: 10 Versuche / 5 Minuten).
|
- **Rate Limiting**: In-Memory-Fenster pro IP für Login (Standard: 10 Versuche / 5 Minuten).
|
||||||
- **Account Lockout**: Nach `LOGIN_MAX_ATTEMPTS` Fehlversuchen wird das Konto für `LOGIN_LOCKOUT_MINUTES` gesperrt.
|
- **Kein Account Lockout**: Fehlversuche sperren das Zielkonto nicht, damit Dritte keine gezielte Konto-DoS auslösen können.
|
||||||
- **Keine User-Enumeration**: Unbekannter Benutzer, inaktiver Benutzer und falsches Passwort liefern dieselbe generische 401-Meldung.
|
- **Keine User-Enumeration**: Unbekannter Benutzer, inaktiver Benutzer und falsches Passwort liefern dieselbe generische 401-Meldung.
|
||||||
- **Audit-Log**: Jeder Login-Versuch (Erfolg/Misserfolg mit Grund), Logout.
|
- **Audit-Log**: Jeder Login-Versuch (Erfolg/Misserfolg mit Grund), Logout.
|
||||||
|
|
||||||
@@ -210,10 +213,10 @@ Minimal-API je Modul (Phase 6): `GET /health`, `GET /api/manifest`, `GET /api/me
|
|||||||
|
|
||||||
| # | Entscheidung | Begründung |
|
| # | Entscheidung | Begründung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Serverseitige Sessions statt reiner JWTs | Widerrufbar (Logout, Deaktivierung), kein Token-Diebstahl-Risiko, einfache Lockout-Logik; JWT/Access-Tokens für die Modul-Kommunikation ab Phase 6 |
|
| 1 | Serverseitige Sessions statt reiner JWTs | Widerrufbar (Logout, Deaktivierung); Modulidentität wird über kurzlebige, signierte Gateway-Assertions übergeben |
|
||||||
| 2 | Opaque 256-Bit-Token, DB speichert nur SHA-256-Hash | DB-Leck kompromittiert keine Sessions |
|
| 2 | Opaque 256-Bit-Token, DB speichert nur SHA-256-Hash | DB-Leck kompromittiert keine Sessions |
|
||||||
| 3 | PostgreSQL außerhalb des Applikationscontainers | Persistenz bei Container-Updates, klare Trennung von Zustand und Compute |
|
| 3 | PostgreSQL außerhalb des Applikationscontainers | Persistenz bei Container-Updates, klare Trennung von Zustand und Compute |
|
||||||
| 4 | Supervisor statt systemd im Container | Container-Standard, verwaltet mehrere Prozesse ohne Root, Crash-Recovery |
|
| 4 | Supervisor statt systemd im Container | Restricted-capability supervisor starts least-privilege services and monitors crashes |
|
||||||
| 5 | Nginx im Container als einziger öffentlicher Endpunkt | Zentrales Routing, Security-Header, interne Ports bleiben verborgen |
|
| 5 | Nginx im Container als einziger öffentlicher Endpunkt | Zentrales Routing, Security-Header, interne Ports bleiben verborgen |
|
||||||
| 6 | `pg` + eigener Migration-Runner statt ORM | Wenig Abhängigkeiten, volle SQL-Kontrolle, Migrationen transaktionssicher mit Advisory-Lock |
|
| 6 | `pg` + eigener Migration-Runner statt ORM | Wenig Abhängigkeiten, volle SQL-Kontrolle, Migrationen transaktionssicher mit Advisory-Lock |
|
||||||
| 7 | Zod statt class-validator | Ein Validierungs-Framework für Frontend und Backend, TypeScript-Inferenz |
|
| 7 | Zod statt class-validator | Ein Validierungs-Framework für Frontend und Backend, TypeScript-Inferenz |
|
||||||
|
|||||||
64
docs/MODULE-MARKETPLACE.md
Normal file
64
docs/MODULE-MARKETPLACE.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Modul-Marketplace: Verbindungen und Katalog
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Die Modulverwaltung bietet neben installierten Modulen einen Marketplace. Administratoren verbinden dort GitHub oder eine Gitea-/Forgejo-Instanz, wählen ausdrücklich Repositories als Katalogquellen aus und installieren veröffentlichte Modul-Releases per Klick.
|
||||||
|
|
||||||
|
```text
|
||||||
|
OAuth-Verbindung -> Repository auswählen -> Release-Katalog -> Paket prüfen -> Installieren
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verbindungen
|
||||||
|
|
||||||
|
- OAuth Authorization Code mit `state` und PKCE (S256).
|
||||||
|
- Tokens bleiben ausschließlich im Backend, werden verschlüsselt gespeichert, nie in API-Antworten aufgenommen und bei jeder Verwendung nur mit minimal notwendigen Leserechten eingesetzt.
|
||||||
|
- Verbindung, Katalogquelle und installierte Module sind getrennte Datensätze. Trennen einer Verbindung entfernt keine installierten Module.
|
||||||
|
- Selbst gehostete Gitea-/Forgejo-Instanzen benötigen eine OAuth-Anwendung auf der jeweiligen Instanz. Callback-URL ist die öffentliche MPM-URL plus `/api/v1/marketplace/oauth/<anbieter>/callback`.
|
||||||
|
- OAuth-Client-Secrets und Verschlüsselungsschlüssel gehören in die Serverkonfiguration bzw. einen Secret Store, nie in das Frontend oder Repository.
|
||||||
|
|
||||||
|
## Katalog und Installation
|
||||||
|
|
||||||
|
- Der Katalog lädt öffentliche Repositories verbundener Forge-Konten. Installiert wird der aktuelle Stand des jeweiligen Standard-Branches.
|
||||||
|
- MPM lädt das vom Forge erzeugte Quellarchiv serverseitig, entfernt den Archiv-Stammordner und erwartet `module.json` im Repository-Stamm.
|
||||||
|
|
||||||
|
## Container-Vertrag für Module
|
||||||
|
|
||||||
|
Installierbare Module müssen neben `module.json` eine Compose-Datei und einen App-Service enthalten:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"composeFile": "compose.yml",
|
||||||
|
"appService": "app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der App-Service muss den Manifest-Port im Container bereitstellen (`expose`, kein `ports`) und auf `0.0.0.0` lauschen. Datenbanken gehören als weitere Services in dieselbe Compose-Datei. Die Dienste teilen ein privates Compose-Netz; nur der App-Service wird zusätzlich an das MPM-Gateway angeschlossen. Für SQLite kann der App-Service `/var/lib/mpm-module` als persistenten Speicher unter `MPM_MODULE_DATA_DIR` verwenden. Datenbankcontainer definieren eigene projektlokale named volumes.
|
||||||
|
|
||||||
|
MPM startet/stoppt den gesamten Stack gemeinsam. Beim Entfernen löscht Compose alle App- und Datenbankcontainer samt Projekt-Netzwerk; benannte Datenvolumes bleiben standardmäßig erhalten, damit ein Entfernen der App keine Daten vernichtet. Pakete dürfen keine Host-Ports, Host-Verzeichnisse, externen Docker-Ressourcen, privilegierten Optionen oder Docker-Socket-Mounts anfordern. Die Modulverwaltung benötigt Zugriff auf den Docker-Socket des Hosts; deshalb dürfen nur vertrauenswürdige Administratoren Module installieren.
|
||||||
|
- Vor der Installation prüft MPM Downloadgröße, Archivpfade, Symlinks, Manifest und Modul-ID. Die bestehende `ModuleInstaller`-Validierung bleibt die letzte Instanz.
|
||||||
|
- Der Browser übermittelt keine Download-URL; MPM erstellt sie aus Anbieter, Besitzer, Repository und Standard-Branch.
|
||||||
|
- Die Installation registriert das Modul. Das Starten bleibt ein separater Lifecycle-Schritt und erfolgt erst nach Bestätigung durch den Administrator.
|
||||||
|
- Verbindungen, Quellrepository, Release-Tag und Prüfsumme werden im Audit-Log erfasst; Secrets und Tokens niemals.
|
||||||
|
|
||||||
|
## GitHub-Rechte
|
||||||
|
|
||||||
|
Für den ersten Verbindungs-Test wird eine GitHub OAuth App mit `read:user` verwendet. Das erlaubt MPM, das autorisierte Konto anzuzeigen; öffentliche Release-Kataloge können separat unauthentifiziert gelesen werden. Private GitHub-Repositories werden in dieser ersten Phase nicht abgerufen. Dafür soll später eine GitHub App mit `Contents: read` eingesetzt werden: OAuth Apps bieten für Quellcode keinen reinen Read-only-Scope und der klassische `repo`-Scope umfasst Schreibzugriff.
|
||||||
|
|
||||||
|
Gitea und Forgejo verwenden kompatible Release- und Repository-APIs, aber jede selbst gehostete Instanz bleibt eine eigene OAuth-Konfiguration und eine explizit vertrauenswürdige Quelle.
|
||||||
|
|
||||||
|
## Umsetzungsschritte
|
||||||
|
|
||||||
|
1. OAuth-Provider-Konfiguration und verschlüsselte Zugangsdaten samt Datenbankmigration.
|
||||||
|
2. OAuth-Start/Callback mit zufälligem, einmalig verwendbarem `state`, Ablaufzeit, PKCE und Bindung an die anmeldende Admin-Session.
|
||||||
|
3. Verbindungen anzeigen und trennen; niemals Tokens zurückgeben.
|
||||||
|
4. Öffentliche Repositories verbundener Forge-Konten direkt als installierbaren Katalog anzeigen.
|
||||||
|
5. Repository-Archive vom Standard-Branch laden und vor dem Installieren sicher normalisieren.
|
||||||
|
6. Installation in die bestehende Modulregistrierung integrieren, auditieren und im UI anzeigen. Module starten nach der Installation nicht automatisch.
|
||||||
|
|
||||||
|
## Aktueller Umfang
|
||||||
|
|
||||||
|
Der Marketplace in der Modulverwaltung unterstützt öffentliche Repositories von GitHub, Gitea und Forgejo. Private Repositories, Suche über fremde Katalogserver und Updates installierter Module sind noch nicht enthalten.
|
||||||
|
|
||||||
|
## Voraussetzungen für den Betrieb
|
||||||
|
|
||||||
|
Für die OAuth-Anmeldung müssen je Provider/Instanz Client-ID und Client-Secret in MPM hinterlegt werden. Die öffentliche HTTPS-URL der Plattform muss stabil sein, da sie als OAuth-Callback registriert wird. Vor Aktivierung privater GitHub-Repositories ist die GitHub-App mit Read-only-Berechtigung zu konfigurieren.
|
||||||
@@ -26,7 +26,7 @@ Definition of Done:
|
|||||||
- [x] PostgreSQL mit persistentem Volume
|
- [x] PostgreSQL mit persistentem Volume
|
||||||
- [x] Migrationen (eigener Runner mit Advisory-Lock) + Seed (Rollen, Admin)
|
- [x] Migrationen (eigener Runner mit Advisory-Lock) + Seed (Rollen, Admin)
|
||||||
- [x] Login / Logout (Argon2id, serverseitige Sessions, HttpOnly-Cookies)
|
- [x] Login / Logout (Argon2id, serverseitige Sessions, HttpOnly-Cookies)
|
||||||
- [x] CSRF-Schutz, Rate Limiting, Account Lockout
|
- [x] CSRF-Schutz und IP-basiertes Rate Limiting
|
||||||
- [x] User-Modell & Rollen (ADMIN/USER, RBAC-Guards)
|
- [x] User-Modell & Rollen (ADMIN/USER, RBAC-Guards)
|
||||||
- [x] Audit-Log (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT)
|
- [x] Audit-Log (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT)
|
||||||
- [x] Health-Endpoint (`/api/v1/health`)
|
- [x] Health-Endpoint (`/api/v1/health`)
|
||||||
@@ -58,7 +58,7 @@ Definition of Done: Modul-Datenmodell, Manifest, Installation, Registrierung, St
|
|||||||
- [x] `modules`-Tabelle (Migration 002) mit Lifecycle-Status und eindeutigen Ports/Slugs
|
- [x] `modules`-Tabelle (Migration 002) mit Lifecycle-Status und eindeutigen Ports/Slugs
|
||||||
- [x] Manifest-Vertrag `module.json` (Zod: ID, Name, Version, Slug, Runtime, Entrypoint, Port 41000–41999, Healthcheck, apiVersion)
|
- [x] Manifest-Vertrag `module.json` (Zod: ID, Name, Version, Slug, Runtime, Entrypoint, Port 41000–41999, Healthcheck, apiVersion)
|
||||||
- [x] ZIP-Installer mit Manifest-Validierung, Größenlimit (10 MB) und **Zip-Slip-Schutz**
|
- [x] ZIP-Installer mit Manifest-Validierung, Größenlimit (10 MB) und **Zip-Slip-Schutz**
|
||||||
- [x] Modul-Prozess-Manager: Start/Stop (SIGTERM→SIGKILL) als Kindprozesse, minimale ENV (keine Plattform-Secrets), eigene Log-Dateien
|
- [x] Modul-Lifecycle: Compose-Containerstacks pro Modul, Datenbanken als separate Services und persistente Named Volumes
|
||||||
- [x] Healthcheck-Service mit Startup-Grace (10 Retries × 500 ms)
|
- [x] Healthcheck-Service mit Startup-Grace (10 Retries × 500 ms)
|
||||||
- [x] Lifecycle: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED, ERROR, DISABLED
|
- [x] Lifecycle: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED, ERROR, DISABLED
|
||||||
- [x] `GET/POST /api/v1/modules`, `POST :id/start|stop|restart`, `PATCH :id/enabled`, `GET :id/health`, `DELETE :id` (nur ADMIN)
|
- [x] `GET/POST /api/v1/modules`, `POST :id/start|stop|restart`, `PATCH :id/enabled`, `GET :id/health`, `DELETE :id` (nur ADMIN)
|
||||||
|
|||||||
6
modules/demo/Dockerfile
Normal file
6
modules/demo/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
USER node
|
||||||
|
EXPOSE 41001
|
||||||
|
CMD ["node", "backend/server.js"]
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const { createHmac, timingSafeEqual } = require('node:crypto');
|
||||||
|
|
||||||
/** Identitäts-Header, die der Modul-Gateway setzt. */
|
/** Identitäts-Header, die der Modul-Gateway setzt. */
|
||||||
const GATEWAY_HEADERS = Object.freeze({
|
const GATEWAY_HEADERS = Object.freeze({
|
||||||
@@ -82,6 +83,29 @@ function extractIdentity(headers) {
|
|||||||
return { userId, username, displayName: displayName ?? username, role };
|
return { userId, username, displayName: displayName ?? username, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate the gateway's signature, bound to this module and exact HTTP request. */
|
||||||
|
function extractVerifiedIdentity(headers, request, moduleId, identityKey) {
|
||||||
|
const identity = extractIdentity(headers);
|
||||||
|
const timestamp = readHeader(headers, 'x-mpm-identity-timestamp');
|
||||||
|
const supplied = readHeader(headers, 'x-mpm-identity-signature');
|
||||||
|
if (!identity || !timestamp || !supplied || !identityKey) return null;
|
||||||
|
const timestampNumber = Number(timestamp);
|
||||||
|
if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > 30_000) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fields = [moduleId, request.method, request.url ?? '/', timestamp,
|
||||||
|
identity.userId, identity.username, identity.displayName, identity.role];
|
||||||
|
const expected = createHmac('sha256', identityKey).update(JSON.stringify(fields)).digest();
|
||||||
|
let actual;
|
||||||
|
try {
|
||||||
|
actual = Buffer.from(supplied, 'hex');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
/** Erstellt die /health-Antwort nach Vertrag. */
|
/** Erstellt die /health-Antwort nach Vertrag. */
|
||||||
function healthResponse(moduleId, version) {
|
function healthResponse(moduleId, version) {
|
||||||
return { moduleId, version, status: 'healthy' };
|
return { moduleId, version, status: 'healthy' };
|
||||||
@@ -280,6 +304,15 @@ function createModuleServer(options) {
|
|||||||
const permissions = options.permissions ?? [];
|
const permissions = options.permissions ?? [];
|
||||||
const logger = options.logger ?? createLogger({ moduleId: manifest.id });
|
const logger = options.logger ?? createLogger({ moduleId: manifest.id });
|
||||||
const identityRequired = options.identityRequired ?? true;
|
const identityRequired = options.identityRequired ?? true;
|
||||||
|
const identityKey = options.identityKey ?? process.env.MPM_MODULE_IDENTITY_KEY ?? null;
|
||||||
|
const requireSignedIdentity = options.requireSignedIdentity ?? process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
function requestIdentity(request) {
|
||||||
|
if (identityKey) {
|
||||||
|
return extractVerifiedIdentity(request.headers, request, manifest.id, identityKey);
|
||||||
|
}
|
||||||
|
return requireSignedIdentity ? null : extractIdentity(request.headers);
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(response, status, body) {
|
function sendJson(response, status, body) {
|
||||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
@@ -299,7 +332,7 @@ function createModuleServer(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/me') {
|
if (request.method === 'GET' && pathname === '/api/me') {
|
||||||
const identity = extractIdentity(request.headers);
|
const identity = requestIdentity(request);
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
||||||
return;
|
return;
|
||||||
@@ -308,7 +341,7 @@ function createModuleServer(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const identity = extractIdentity(request.headers);
|
const identity = requestIdentity(request);
|
||||||
if (identityRequired && routes && !identity) {
|
if (identityRequired && routes && !identity) {
|
||||||
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
||||||
return;
|
return;
|
||||||
@@ -336,6 +369,7 @@ module.exports = {
|
|||||||
GATEWAY_HEADERS,
|
GATEWAY_HEADERS,
|
||||||
PLATFORM_ROLES,
|
PLATFORM_ROLES,
|
||||||
extractIdentity,
|
extractIdentity,
|
||||||
|
extractVerifiedIdentity,
|
||||||
healthResponse,
|
healthResponse,
|
||||||
manifestResponse,
|
manifestResponse,
|
||||||
meResponse,
|
meResponse,
|
||||||
|
|||||||
@@ -35,6 +35,6 @@ const server = createModuleServer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, '127.0.0.1', () => {
|
server.listen(port, '0.0.0.0', () => {
|
||||||
logger.info('Demo-Modul gestartet', { port });
|
logger.info('Demo-Modul gestartet', { port });
|
||||||
});
|
});
|
||||||
8
modules/demo/compose.yml
Normal file
8
modules/demo/compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
expose:
|
||||||
|
- "41001"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -9,5 +9,7 @@
|
|||||||
"entrypoint": "server.js",
|
"entrypoint": "server.js",
|
||||||
"port": 41001,
|
"port": 41001,
|
||||||
"healthcheck": "/health",
|
"healthcheck": "/health",
|
||||||
"apiVersion": "v1"
|
"apiVersion": "v1",
|
||||||
|
"composeFile": "compose.yml",
|
||||||
|
"appService": "app"
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,8 @@ Der Server verdrahtet automatisch den Vertrag:
|
|||||||
## API
|
## API
|
||||||
|
|
||||||
### `extractIdentity(headers)`
|
### `extractIdentity(headers)`
|
||||||
|
|
||||||
|
> This helper only parses untrusted header values. Do not use it as an authorization check. `createModuleServer` verifies the request-bound, per-module signature before exposing the identity to `/api/me` or route handlers. In production it requires the key injected as `MPM_MODULE_IDENTITY_KEY`.
|
||||||
Liest die Benutzer-Identität aus den Gateway-Headern (`x-user-id`,
|
Liest die Benutzer-Identität aus den Gateway-Headern (`x-user-id`,
|
||||||
`x-user-username`, `x-user-display-name`, `x-user-role`). Case-insensitive;
|
`x-user-username`, `x-user-display-name`, `x-user-role`). Case-insensitive;
|
||||||
`null`, wenn der Request nicht über den Gateway kam.
|
`null`, wenn der Request nicht über den Gateway kam.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const { createHmac, timingSafeEqual } = require('node:crypto');
|
||||||
|
|
||||||
/** Identitäts-Header, die der Modul-Gateway setzt. */
|
/** Identitäts-Header, die der Modul-Gateway setzt. */
|
||||||
const GATEWAY_HEADERS = Object.freeze({
|
const GATEWAY_HEADERS = Object.freeze({
|
||||||
@@ -82,6 +83,29 @@ function extractIdentity(headers) {
|
|||||||
return { userId, username, displayName: displayName ?? username, role };
|
return { userId, username, displayName: displayName ?? username, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate the gateway's signature, bound to this module and exact HTTP request. */
|
||||||
|
function extractVerifiedIdentity(headers, request, moduleId, identityKey) {
|
||||||
|
const identity = extractIdentity(headers);
|
||||||
|
const timestamp = readHeader(headers, 'x-mpm-identity-timestamp');
|
||||||
|
const supplied = readHeader(headers, 'x-mpm-identity-signature');
|
||||||
|
if (!identity || !timestamp || !supplied || !identityKey) return null;
|
||||||
|
const timestampNumber = Number(timestamp);
|
||||||
|
if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > 30_000) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fields = [moduleId, request.method, request.url ?? '/', timestamp,
|
||||||
|
identity.userId, identity.username, identity.displayName, identity.role];
|
||||||
|
const expected = createHmac('sha256', identityKey).update(JSON.stringify(fields)).digest();
|
||||||
|
let actual;
|
||||||
|
try {
|
||||||
|
actual = Buffer.from(supplied, 'hex');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
/** Erstellt die /health-Antwort nach Vertrag. */
|
/** Erstellt die /health-Antwort nach Vertrag. */
|
||||||
function healthResponse(moduleId, version) {
|
function healthResponse(moduleId, version) {
|
||||||
return { moduleId, version, status: 'healthy' };
|
return { moduleId, version, status: 'healthy' };
|
||||||
@@ -280,6 +304,15 @@ function createModuleServer(options) {
|
|||||||
const permissions = options.permissions ?? [];
|
const permissions = options.permissions ?? [];
|
||||||
const logger = options.logger ?? createLogger({ moduleId: manifest.id });
|
const logger = options.logger ?? createLogger({ moduleId: manifest.id });
|
||||||
const identityRequired = options.identityRequired ?? true;
|
const identityRequired = options.identityRequired ?? true;
|
||||||
|
const identityKey = options.identityKey ?? process.env.MPM_MODULE_IDENTITY_KEY ?? null;
|
||||||
|
const requireSignedIdentity = options.requireSignedIdentity ?? process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
function requestIdentity(request) {
|
||||||
|
if (identityKey) {
|
||||||
|
return extractVerifiedIdentity(request.headers, request, manifest.id, identityKey);
|
||||||
|
}
|
||||||
|
return requireSignedIdentity ? null : extractIdentity(request.headers);
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(response, status, body) {
|
function sendJson(response, status, body) {
|
||||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
@@ -299,7 +332,7 @@ function createModuleServer(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/me') {
|
if (request.method === 'GET' && pathname === '/api/me') {
|
||||||
const identity = extractIdentity(request.headers);
|
const identity = requestIdentity(request);
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
||||||
return;
|
return;
|
||||||
@@ -308,7 +341,7 @@ function createModuleServer(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const identity = extractIdentity(request.headers);
|
const identity = requestIdentity(request);
|
||||||
if (identityRequired && routes && !identity) {
|
if (identityRequired && routes && !identity) {
|
||||||
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
|
||||||
return;
|
return;
|
||||||
@@ -336,6 +369,7 @@ module.exports = {
|
|||||||
GATEWAY_HEADERS,
|
GATEWAY_HEADERS,
|
||||||
PLATFORM_ROLES,
|
PLATFORM_ROLES,
|
||||||
extractIdentity,
|
extractIdentity,
|
||||||
|
extractVerifiedIdentity,
|
||||||
healthResponse,
|
healthResponse,
|
||||||
manifestResponse,
|
manifestResponse,
|
||||||
meResponse,
|
meResponse,
|
||||||
|
|||||||
49
scripts/fix-mojibake.ps1
Normal file
49
scripts/fix-mojibake.ps1
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Repariert doppel-kodierte UTF-8-Zeichen (Mojibake) in einer Datei.
|
||||||
|
# ASCII-only: Alle Sonderzeichen werden programmatisch berechnet,
|
||||||
|
# damit das Skript selbst von Encoding-Problemen unberuehrt bleibt.
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Erzeugt die Mojibake-Variante eines Strings:
|
||||||
|
# UTF-8-Bytes werden als Windows-1252 interpretiert (exakt der Fehler).
|
||||||
|
function ConvertTo-Mojibake([string]$Text) {
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
|
||||||
|
return [System.Text.Encoding]::GetEncoding('windows-1252').GetString($bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Zu korrigierende Zeichen (als Unicode-Codepoints, ASCII-only-Skript)
|
||||||
|
$correctChars = @(
|
||||||
|
[char]0x00FC # ue (klein)
|
||||||
|
[char]0x00F6 # oe (klein)
|
||||||
|
[char]0x00E4 # ae (klein)
|
||||||
|
[char]0x00C4 # Ae (gross)
|
||||||
|
[char]0x00D6 # Oe (gross)
|
||||||
|
[char]0x00DC # Ue (gross)
|
||||||
|
[char]0x00DF # scharfes S
|
||||||
|
[char]0x2013 # en dash
|
||||||
|
[char]0x201E # Anfuehrung unten
|
||||||
|
[char]0x201C # Anfuehrung oben
|
||||||
|
[char]0x2018 # einfaches Anfuehrungszeichen
|
||||||
|
[char]0x2019 # einfaches Anfuehrungszeichen (schliessend)
|
||||||
|
[char]0x2026 # Ellipse
|
||||||
|
[char]0x00B7 # Mittelpunkt
|
||||||
|
[char]0x2014 # em dash
|
||||||
|
)
|
||||||
|
|
||||||
|
$content = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
$replacementCount = 0
|
||||||
|
foreach ($correct in $correctChars) {
|
||||||
|
$mojibake = ConvertTo-Mojibake ([string]$correct)
|
||||||
|
if ($content.Contains($mojibake)) {
|
||||||
|
$count = ([regex]::Matches($content, [regex]::Escape($mojibake))).Count
|
||||||
|
$content = $content.Replace($mojibake, $correct)
|
||||||
|
$replacementCount += $count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||||
|
[System.IO.File]::WriteAllText($Path, $content, $utf8NoBom)
|
||||||
|
Write-Output "Korrigiert: $replacementCount Stellen in $Path"
|
||||||
Reference in New Issue
Block a user