Feature Request: admin session security
- replace bearer auth with session+CSRF flow and add admin user directory - update frontend moderation flow, force password change gate, and new CLI - refresh changelog/docs/feature plan + ensure swagger dev experience
This commit is contained in:
parent
fb4b3b95a6
commit
6332b82c6a
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unterschiedliche Zugriffslevel:
|
||||
|
||||
### 1. Admin-Routes (Bearer Token)
|
||||
### 1. Admin-Routes (Session + CSRF)
|
||||
- **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
|
||||
- **Methode**: Bearer Token im Authorization Header
|
||||
- **Konfiguration**: `.env` → `ADMIN_API_KEY`
|
||||
- **Methode**: HTTP Session (Cookie) + CSRF-Token
|
||||
- **Konfiguration**: `.env` → `ADMIN_SESSION_SECRET` (+ Admin-Benutzer in DB)
|
||||
|
||||
### 2. Management-Routes (UUID Token)
|
||||
- **Zweck**: Self-Service Portal für Gruppen-Verwaltung
|
||||
|
|
@ -20,36 +20,54 @@ Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unte
|
|||
|
||||
### Setup
|
||||
|
||||
1. **Sicheren Admin-Key generieren**:
|
||||
```bash
|
||||
# Linux/Mac:
|
||||
openssl rand -hex 32
|
||||
|
||||
# Oder Node.js:
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
2. **In `.env` eintragen**:
|
||||
1. **Session Secret setzen**:
|
||||
```env
|
||||
ADMIN_API_KEY=dein-generierter-key-hier
|
||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
```
|
||||
2. **Backend starten** – Migration legt Tabelle `admin_users` an.
|
||||
3. **Setup-Status prüfen**:
|
||||
```bash
|
||||
curl -c cookies.txt http://localhost:5000/auth/setup/status
|
||||
```
|
||||
4. **Initialen Admin anlegen** (nur wenn `needsSetup=true`):
|
||||
```bash
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123!"}' \
|
||||
http://localhost:5000/auth/setup/initial-admin
|
||||
```
|
||||
5. **Login für weitere Sessions**:
|
||||
```bash
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123!"}' \
|
||||
http://localhost:5000/auth/login
|
||||
```
|
||||
6. **CSRF Token abrufen** (für mutierende Requests):
|
||||
```bash
|
||||
curl -b cookies.txt http://localhost:5000/auth/csrf-token
|
||||
```
|
||||
|
||||
3. **Server neu starten**
|
||||
|
||||
### Verwendung
|
||||
|
||||
Alle Requests an `/api/admin/*` benötigen den Authorization Header:
|
||||
Alle `/api/admin/*`- und `/api/system/*`-Routen setzen voraus:
|
||||
|
||||
1. Browser sendet automatisch das Session-Cookie (`sid`).
|
||||
2. Für POST/PUT/PATCH/DELETE muss der Header `X-CSRF-Token` gesetzt werden.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer dein-generierter-key-hier" \
|
||||
http://localhost:5000/api/admin/deletion-log
|
||||
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
|
||||
curl -X PATCH \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: $CSRF" \
|
||||
-b cookies.txt \
|
||||
-d '{"approved":true}' \
|
||||
http://localhost:5000/api/admin/groups/abc123/approve
|
||||
```
|
||||
|
||||
**Postman/Insomnia**:
|
||||
- Type: `Bearer Token`
|
||||
- Token: `dein-generierter-key-hier`
|
||||
|
||||
### Geschützte Endpoints
|
||||
### Geschützte Endpoints (Auszug)
|
||||
|
||||
| Endpoint | Method | Beschreibung |
|
||||
|----------|--------|--------------|
|
||||
|
|
@ -58,17 +76,18 @@ curl -H "Authorization: Bearer dein-generierter-key-hier" \
|
|||
| `/api/admin/cleanup/run` | POST | Cleanup manuell starten |
|
||||
| `/api/admin/cleanup/status` | GET | Cleanup Status |
|
||||
| `/api/admin/rate-limiter/stats` | GET | Rate-Limiter Statistiken |
|
||||
| `/api/admin/management-audit` | GET | Management Audit Log |
|
||||
| `/api/admin/groups` | GET | Alle Gruppen (Moderation) |
|
||||
| `/api/admin/groups/:id/approve` | PUT | Gruppe freigeben |
|
||||
| `/api/admin/groups/:id/approve` | PATCH | Gruppe freigeben |
|
||||
| `/api/admin/groups/:id` | DELETE | Gruppe löschen |
|
||||
| `/api/system/migration/*` | POST | Migrationswerkzeuge |
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Status | Bedeutung |
|
||||
|--------|-----------|
|
||||
| `403` | Authorization header fehlt oder ungültig |
|
||||
| `500` | ADMIN_API_KEY nicht konfiguriert |
|
||||
| `401` | Session fehlt oder ist abgelaufen |
|
||||
| `403` | CSRF ungültig oder Benutzer hat keine Admin-Rolle |
|
||||
| `419` | (optional) Session wurde invalidiert |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -147,42 +166,43 @@ npm test
|
|||
|
||||
### Manuelles Testen
|
||||
|
||||
**Admin-Route ohne Auth**:
|
||||
```bash
|
||||
curl http://localhost:5000/api/admin/deletion-log
|
||||
# → 403 Forbidden
|
||||
```
|
||||
|
||||
**Admin-Route mit Auth**:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your-key" \
|
||||
http://localhost:5000/api/admin/deletion-log
|
||||
# → 200 OK
|
||||
```
|
||||
1. **Login**:
|
||||
```bash
|
||||
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"Secret123"}' \
|
||||
http://localhost:5000/auth/login
|
||||
```
|
||||
2. **CSRF holen**:
|
||||
```bash
|
||||
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
|
||||
```
|
||||
3. **Admin-Route aufrufen**:
|
||||
```bash
|
||||
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" http://localhost:5000/api/admin/deletion-log
|
||||
# → 200 OK
|
||||
```
|
||||
4. **Ohne Session** (z. B. Cookies löschen) → Request liefert `403 SESSION_REQUIRED`.
|
||||
|
||||
---
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] `ADMIN_API_KEY` mit sicherem 64-Zeichen Key setzen
|
||||
- [ ] `ADMIN_SESSION_SECRET` sicher generieren (>= 32 Bytes random)
|
||||
- [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
|
||||
- [ ] HTTPS verwenden (TLS/SSL)
|
||||
- [ ] Rate Limiting aktiviert prüfen
|
||||
- [ ] Audit Logs regelmäßig überprüfen
|
||||
- [ ] Token-Rotation Policy für Admin-Key implementieren
|
||||
- [ ] HTTPS verwenden (TLS/SSL) damit Cookies `Secure` gesetzt werden können
|
||||
- [ ] Session-Store auf persistentem Volume ablegen
|
||||
- [ ] Rate Limiting & Audit Logs überwachen
|
||||
- [ ] Admin-Benutzerverwaltung (On-/Offboarding) dokumentieren
|
||||
|
||||
---
|
||||
|
||||
## Sicherheits-Hinweise
|
||||
|
||||
### Admin-Key Rotation
|
||||
### Session-Secret Rotation
|
||||
|
||||
Admin-Key regelmäßig erneuern (z.B. alle 90 Tage):
|
||||
|
||||
1. Neuen Key generieren
|
||||
2. `.env` aktualisieren
|
||||
3. Server neu starten
|
||||
4. Alte Clients auf neuen Key umstellen
|
||||
1. Wartungsfenster planen (alle Sessions werden invalidiert)
|
||||
2. Neuen `ADMIN_SESSION_SECRET` generieren
|
||||
3. `.env` aktualisieren und Backend neu starten
|
||||
|
||||
### Management-Token
|
||||
|
||||
|
|
@ -192,8 +212,8 @@ Admin-Key regelmäßig erneuern (z.B. alle 90 Tage):
|
|||
|
||||
### Best Practices
|
||||
|
||||
- Admin-Key **nie** im Code hart-kodieren
|
||||
- Admin-Key **nie** in Logs/Errors ausgeben
|
||||
- Requests über HTTPS (kein Plain-HTTP in Production)
|
||||
- Rate-Limiting für beide Auth-Typen aktiv
|
||||
- Keine Admin-Secrets im Frontend oder in Repos committen
|
||||
- Admin-Session-Cookies nur über HTTPS ausliefern
|
||||
- Rate-Limiting für beide Auth-Typen aktiv halten
|
||||
- Audit-Logs regelmäßig auf Anomalien prüfen
|
||||
- Session-Store-Backups schützen (enthalten Benutzer-IDs)
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -1,5 +1,27 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased] - Branch: feature/security
|
||||
|
||||
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
|
||||
|
||||
#### Backend
|
||||
- ✅ **Server-Side Sessions + CSRF**: Replaced Bearer-token auth with HttpOnly session cookies backed by SQLite, added `requireAdminAuth` + `requireCsrf` middlewares, and exposed `GET /auth/csrf-token` for clients.
|
||||
- ✅ **New Auth Lifecycle**: Added `GET /auth/setup/status`, `POST /auth/setup/initial-admin`, `POST /auth/login`, `POST /auth/logout`, `POST /auth/change-password`, and `POST /api/admin/users` to support onboarding, login, rotation, and creating additional admins.
|
||||
- ✅ **Admin Directory**: Introduced `admin_users` table, repository, and `AdminAuthService` (hash/verify, forced password change flag, audit-friendly responses) plus Jest coverage for the new flows.
|
||||
- ✅ **OpenAPI & Swagger Stability**: Regenerate spec on dev start only, ignore `docs/openapi.json` in nodemon watches, and expose Swagger UI reliably at `http://localhost:5001/api/docs/`.
|
||||
|
||||
#### Frontend
|
||||
- ✅ **Admin Session Context**: New `AdminSessionProvider` manages setup/login state, CSRF persistence, and guards moderation routes via `AdminSessionGate`.
|
||||
- ✅ **Force Password Change UX**: Added `ForcePasswordChangeForm`, change-password API helper, and conditional gate that blocks moderation access until the first login password is rotated.
|
||||
- ✅ **Management UI Updates**: Moderation/management pages now assume cookie-based auth, automatically attach CSRF headers, and gracefully handle session expiry.
|
||||
|
||||
#### Tooling & Scripts
|
||||
- ✅ **API-Driven CLI**: Replaced the legacy Node-only helper with `scripts/create_admin_user.sh`, which can bootstrap the first admin or log in via API to add additional admins from any Linux machine.
|
||||
- ✅ **Docker & Docs Alignment**: Updated dev/prod compose files, Nginx configs, and `README*`/`AUTHENTICATION.md`/`frontend/MIGRATION-GUIDE.md` to describe the new security model and CLI workflow.
|
||||
- ✅ **Feature Documentation**: Added `FeatureRequests/FEATURE_PLAN-security.md` + `FEATURE_TESTPLAN-security.md` outlining design, validation steps, and residual follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - Branch: feature/SocialMedia
|
||||
|
||||
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
|
||||
|
|
@ -56,7 +78,7 @@
|
|||
|
||||
- ✅ **OpenAPI Auto-Generation**:
|
||||
- Automatic spec generation on backend start (dev mode)
|
||||
- Swagger UI available at `/api/docs` in development
|
||||
- Swagger UI available at `/api/docs/` in development
|
||||
- Skip generation in test and production modes
|
||||
|
||||
#### Bug Fixes
|
||||
|
|
|
|||
109
FeatureRequests/FEATURE_PLAN-security.md
Normal file
109
FeatureRequests/FEATURE_PLAN-security.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Feature Plan: Server-seitige Sessions für Admin-API
|
||||
|
||||
## Kontext
|
||||
- Ziel: Admin-API auf serverseitige Sessions mit CSRF-Schutz umstellen, Secrets ausschließlich backendseitig halten.
|
||||
- Initialer Admin wird über einen Setup-Wizard in der Admin-UI angelegt; weitere Admins werden in einer neuen `admin_users`-Tabelle verwaltet.
|
||||
- Session-Cookies (HttpOnly, Secure, SameSite=Strict) und SQLite-basierter Session-Store.
|
||||
|
||||
## Annahmen & Randbedingungen
|
||||
1. Backend nutzt weiterhin SQLite; Session-Daten liegen in separater Datei (`sessions.sqlite`).
|
||||
2. Session-Secret (`ADMIN_SESSION_SECRET`) bleibt als ENV-Variable im Backend.
|
||||
3. Frontend authentifiziert sich ausschließlich via Session-Cookie + `X-CSRF-Token`; keine Bearer-Tokens im Browser.
|
||||
4. Initialer Admin wird per UI-Wizard erstellt; falls Wizard nicht verfügbar ist, gibt es ein Fallback-CLI/Script.
|
||||
5. `AUTHENTICATION.md` und `frontend/MIGRATION-GUIDE.md` sind maßgebliche Dokumente für Auth-Flow.
|
||||
|
||||
## Aufgaben-Backlog
|
||||
- [x] **Session Store & Konfiguration**
|
||||
- `express-session` + `connect-sqlite3` installieren und konfigurieren.
|
||||
- Session-Datei z. B. unter `backend/src/data/sessions.sqlite` speichern.
|
||||
- Cookie-Flags gemäß Prod/Dev setzen.
|
||||
|
||||
- [x] **Admin User Datenbank**
|
||||
- Migration / Schema für `admin_users` inkl. Passwort-Hash (bcrypt) und Meta-Feldern.
|
||||
- Seed-/Wizard-Mechanismus für ersten Admin.
|
||||
|
||||
- [x] **Login / Logout Endpoints**
|
||||
- `POST /auth/login` prüft Credentials gegen DB.
|
||||
- `POST /auth/logout` zerstört Session + Cookie.
|
||||
- Bei Login `req.session.user` + `req.session.csrfToken` setzen.
|
||||
|
||||
- [x] **CSRF Token & Middleware**
|
||||
- `GET /auth/csrf-token` (nur authentifizierte Sessions).
|
||||
- Middleware `requireCsrf` für mutierende Admin-/System-Routen.
|
||||
- [x] **Initial Admin Setup Flow (Backend)**
|
||||
- `GET /auth/setup/status` liefert `{ needsSetup: boolean }` basierend auf Admin-Anzahl.
|
||||
- `POST /auth/setup/initial-admin` erlaubt das Anlegen des ersten Admins (nur wenn `needsSetup` true).
|
||||
- UI-Wizard ruft Status ab, zeigt Formular und loggt Admin optional direkt ein.
|
||||
|
||||
## Endpoint-Konzept
|
||||
|
||||
- `POST /auth/setup/initial-admin`
|
||||
- Body: `{ username, password }` (optional `passwordConfirm` auf UI-Seite validieren).
|
||||
- Backend: prüft, dass keine aktiven Admins existieren, erstellt Nutzer (bcrypt Hash) und markiert Session als eingeloggt.
|
||||
- Response: `{ success: true, csrfToken }` und setzt Session-Cookie.
|
||||
|
||||
- `GET /auth/setup/status`
|
||||
- Response: `{ needsSetup: true|false }` plus optional `hasSession: boolean`.
|
||||
|
||||
- `POST /auth/login`
|
||||
- Body: `{ username, password }`.
|
||||
- Checks: User aktiv, Passwort korrekt (bcrypt.compare), optional Rate-Limit.
|
||||
- Side-effects: `req.session.user = { id, username, role }`, `req.session.csrfToken = randomHex(32)`.
|
||||
- Response: `{ success: true, csrfToken }` (Cookie kommt automatisch).
|
||||
|
||||
- `POST /auth/logout`
|
||||
- Destroys session, clears cookie, returns 204/200.
|
||||
|
||||
- `GET /auth/csrf-token`
|
||||
- Requires valid session, returns `{ csrfToken }` (regenerates when missing or `?refresh=true`).
|
||||
|
||||
- Middleware `requireAdminSession`
|
||||
- Prüft `req.session.user?.role === 'admin'` und optional `is_active` Flag.
|
||||
- Antwortet mit `403` + `{ reason: 'SESSION_REQUIRED' }` wenn nicht vorhanden.
|
||||
|
||||
- Middleware `requireCsrf`
|
||||
- Gilt für `POST/PUT/PATCH/DELETE` auf `/api/admin/*` & `/api/system/*`.
|
||||
- Erwartet Header `x-csrf-token`; vergleicht mit `req.session.csrfToken`.
|
||||
- Bei Fehler: `403` + `{ reason: 'CSRF_INVALID' }`.
|
||||
|
||||
- Frontend-Fluss
|
||||
- Nach Login/Setup: speichert gelieferten Token im State.
|
||||
- Für alle Admin-Requests: `fetch(url, { method, credentials: 'include', headers: { 'X-CSRF-Token': token } })`.
|
||||
- Wenn 401/403 wegen Session: UI zeigt Login.
|
||||
|
||||
- [x] **Admin-Auth Middleware**
|
||||
- `/api/admin/*` + `/api/system/*` prüfen Session (`req.session.user.role === 'admin'`).
|
||||
- Alte Token-basierte Checks entfernen.
|
||||
- Ergänzt durch neue öffentliche Route `GET /api/social-media/platforms` (Upload/Management), während Admin-spezifische Plattform-Funktionen weiterhin über `/api/admin/social-media/*` laufen.
|
||||
|
||||
- [x] **Frontend Admin Flow**
|
||||
- `adminApi.js` auf `credentials: 'include'` + `X-CSRF-Token` umbauen.
|
||||
- Login-UI + Setup-Wizard für initialen Admin.
|
||||
- State-Handling für CSRF-Token (Hook/Context) via `AdminSessionProvider` + `AdminSessionGate`.
|
||||
|
||||
- [x] **Secret-Handling & Docker**
|
||||
- `docker/prod/docker-compose.yml` und Backend-Configs geben nur noch `ADMIN_SESSION_SECRET` an.
|
||||
- Frontend-Build enthält keine sensiblen `.env`-Dateien; Public env-config liefert ausschließlich non-sensitive Werte.
|
||||
- Deployment-Dokumentation (`env.sh`, README) beschreibt erlaubte Variablen.
|
||||
|
||||
- [x] **Tests & CI**
|
||||
- Jest-Suites decken Login/CSRF/Admin-Endpunkte ab (`tests/api/*`, `tests/unit/auth.test.js`).
|
||||
- Secret-Grep + Docker-Build-Schritt stellen sicher, dass das Frontend-Bundle keine Admin-Secrets enthält.
|
||||
|
||||
- [x] **Mehrere Admins & CLI-Tooling**
|
||||
- `POST /api/admin/users` + `AdminAuthService.createAdminUser` für zusätzliche Admins.
|
||||
- `scripts/create_admin_user.sh` automatisiert Initial-Setup & weitere Accounts via API.
|
||||
|
||||
- [x] **Passwortrotation erzwingen**
|
||||
- Flag `requires_password_change`, `POST /auth/change-password`, Frontend-Formular blockiert Dashboard bis zur Änderung.
|
||||
|
||||
- [ ] **Key-Leak Reaktionsplan**
|
||||
- Anleitung (Scans, History-Cleanup, Rotation) dokumentieren bzw. verlinken.
|
||||
|
||||
- [x] **Dokumentation**
|
||||
- `AUTHENTICATION.md`, `README(.dev)` und `frontend/MIGRATION-GUIDE.md` beschreiben Session/CSRF-Flow.
|
||||
- Feature-Request-Referenzen zeigen auf neue Session-Implementierung.
|
||||
|
||||
- [ ] **Kommunikation & Review**
|
||||
- Verweise auf relevante Patches/PRs ergänzen.
|
||||
- Reviewer-Hinweise (Testplan, Rollout) dokumentieren.
|
||||
|
|
@ -3,6 +3,13 @@ Feature Request: Server-seitige Session-Authentifizierung für Admin-API
|
|||
Zielgruppe: Entwickler / KI-Implementierer
|
||||
-->
|
||||
|
||||
1. erstelle ein Branch namens `feature/security` aus dem aktuellen `main` Branch.
|
||||
2. erstelle eine Datei `FeatureRequests/FEATURE_PLAN-security.md` in der du die Umsetzungsaufgaben dokumentierst (siehe unten) und darin die TODO Liste erstellst und aktuallisierst.
|
||||
3. Stelle mir Fragen bezüglich der Umsetzung
|
||||
4. Verstehe, wie bisher im Frontend die UI aufgebaut ist (modular, keine inline css, globale app.css)
|
||||
5. Implementiere die untenstehenden Aufgaben Schritt für Schritt.
|
||||
|
||||
|
||||
# FEATURE_REQUEST: Security — Server-seitige Sessions für Admin-API
|
||||
|
||||
Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler)
|
||||
|
|
|
|||
76
FeatureRequests/FEATURE_TESTPLAN-security.md
Normal file
76
FeatureRequests/FEATURE_TESTPLAN-security.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Feature Testplan: Admin-Session-Sicherheit
|
||||
|
||||
## Ziel
|
||||
Sicherstellen, dass die neue serverseitige Admin-Authentifizierung (Session + CSRF) korrekt funktioniert, keine Secrets mehr im Frontend landen und bestehende Upload-/Management-Flows weiterhin laufen.
|
||||
|
||||
## Voraussetzungen
|
||||
- `ADMIN_SESSION_SECRET` ist gesetzt – bei Dev in `docker/dev/backend/config/.env`, bei Prod in `docker/prod/backend/.env`. Wert per `openssl rand -hex 32` generieren.
|
||||
- Docker-Stack läuft (`./dev.sh` bzw. `docker compose -f docker/dev/docker-compose.yml up -d` für Dev oder `docker compose -f docker/prod/docker-compose.yml up -d` für Prod).
|
||||
- Browser-Cookies gelöscht bzw. neue Session (Inkognito) verwenden.
|
||||
- `curl` und `jq` lokal verfügbar (CLI-Aufrufe), Build/Tests laufen innerhalb der Docker-Container.
|
||||
|
||||
## Testumgebungen
|
||||
| Umgebung | Zweck |
|
||||
|----------|-------|
|
||||
| `docker/dev` (localhost) | Haupt-Testumgebung, schnelle Iteration |
|
||||
| Backend-Jest Tests | Regression für API-/Auth-Layer |
|
||||
| Frontend Build (`docker compose exec frontend-dev npm run build`) | Sicherstellen, dass keine Secrets im Bundle landen |
|
||||
|
||||
## Testfälle
|
||||
|
||||
### 1. Initiales Admin-Setup
|
||||
1. `curl -c cookies.txt http://localhost:5001/auth/setup/status` → `needsSetup` prüfen.
|
||||
2. Falls `true`: `curl -X POST -H "Content-Type: application/json" -c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123"}' \
|
||||
http://localhost:5001/auth/setup/initial-admin` → `success: true`, Cookie gesetzt.
|
||||
3. `curl -b cookies.txt http://localhost:5001/auth/setup/status` → `needsSetup:false`, `hasSession:true`.
|
||||
4. `curl -b cookies.txt http://localhost:5001/auth/logout` → 204, Cookie weg.
|
||||
|
||||
### 2. Login & CSRF (Backend-Sicht)
|
||||
1. `curl -X POST -H "Content-Type: application/json" -c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123"}' http://localhost:5001/auth/login`.
|
||||
2. `CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')`.
|
||||
3. `curl -b cookies.txt -H "X-CSRF-Token: $CSRF" http://localhost:5001/api/admin/groups` → 200.
|
||||
4. Fehlerfälle prüfen:
|
||||
- Ohne Cookie → 403 `{ reason: 'SESSION_REQUIRED' }`.
|
||||
- Mit Cookie aber ohne Token → 403 `{ reason: 'CSRF_INVALID' }`.
|
||||
- Mit falschem Token → 403 `{ reason: 'CSRF_INVALID' }`.
|
||||
|
||||
### 3. Moderations-UI (Frontend)
|
||||
1. Browser auf `http://localhost:3000/moderation` → Login oder Setup-Wizard erscheint.
|
||||
2. Wizard ausfüllen (nur beim ersten Start).
|
||||
3. Normales Login durchführen (korrekte & falsche Credentials testen).
|
||||
4. Nach Login folgende Aktionen validieren (Network-Tab kontrollieren: Requests senden Cookies + `X-CSRF-Token`):
|
||||
- Gruppenliste lädt.
|
||||
- Gruppe approve/reject.
|
||||
- Cleanup-Preview/-Trigger (falls Daten vorhanden).
|
||||
- Social-Media-Einstellungen laden/speichern.
|
||||
5. Logout in der UI → Redirect zum Login, erneutes Laden zeigt Login.
|
||||
6. Browser-Refresh nach Logout → kein Zugriff auf Admin-Daten (sollte Login anzeigen).
|
||||
|
||||
### 4. Regression Upload & Management
|
||||
1. Normales Upload-Formular durchspielen (`/`): Gruppe hochladen.
|
||||
2. Management-Link (`/manage/:token`) öffnen, Consents ändern, Bilder verwalten.
|
||||
3. Sicherstellen, dass neue Session-Mechanik nichts davon beeinflusst.
|
||||
|
||||
### 5. Öffentliche APIs
|
||||
1. `curl http://localhost:5001/api/social-media/platforms` → weiterhin öffentlich verfügbar.
|
||||
2. Slideshow & Gruppenübersicht im Frontend testen (`/slideshow`, `/groups`).
|
||||
|
||||
### 6. Bundle-/Secret-Prüfung
|
||||
1. Dev-Stack: `docker compose -f docker/dev/docker-compose.yml exec frontend-dev npm run build` (Prod analog mit `docker/prod`).
|
||||
2. `docker compose -f docker/dev/docker-compose.yml exec frontend-dev sh -c "grep -R 'ADMIN' build/"` → keine geheimen Variablen (nur Dokumentationsstrings erlaubt).
|
||||
3. Falls doch Treffer: Build abbrechen und Ursache analysieren.
|
||||
|
||||
### 7. Automatisierte Tests
|
||||
1. Backend: `docker compose -f docker/dev/docker-compose.yml exec backend-dev npm test` (neue Auth-Tests müssen grün sein).
|
||||
2. Optional: `docker compose -f docker/dev/docker-compose.yml exec frontend-dev npm test` oder vorhandene E2E-Suite per Container laufen lassen.
|
||||
|
||||
### 8. CI/Monitoring Checks
|
||||
- Pipeline-Schritt hinzunehmen, der `curl`-Smoke-Test (Login + `GET /api/admin/groups`) fährt.
|
||||
- Optionaler Script-Check, der das Frontend-Bundle auf Secrets scannt.
|
||||
|
||||
## Testabschluss
|
||||
- Alle oben genannten Schritte erfolgreich? → Feature gilt als verifiziert.
|
||||
- Offene Findings dokumentieren in `FeatureRequests/FEATURE_PLAN-security.md` (Status + Follow-up).
|
||||
- Nach Freigabe: Reviewer informieren, Deploy-Plan (z. B. neue Session-Secret-Verteilung) abstimmen.
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
1. ✅ **OpenAPI Auto-Generation:** Swagger Spec wird automatisch aus Route-Definitionen generiert
|
||||
2. ✅ **Konsistente API-Struktur:** Klare, REST-konforme API-Organisation für einfache KI-Navigation
|
||||
3. ✅ **Single Source of Truth:** `routeMappings.js` als zentrale Route-Konfiguration
|
||||
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs` (dev-only)
|
||||
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs/` (dev-only)
|
||||
5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing
|
||||
6. ✅ **API Security:** Bearer Token Authentication für Admin-Endpoints
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ npm run dev
|
|||
# 4. Tests schreiben: tests/api/newRoute.test.js
|
||||
npm test
|
||||
|
||||
# 5. Swagger UI: http://localhost:5001/api/docs
|
||||
# 5. Swagger UI: http://localhost:5001/api/docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
## Anforderungen an das Feature
|
||||
1. Beim lokalen Dev‑Start soll eine OpenAPI Spec erzeugt werden (z. B. mit `swagger-autogen` oder `express-oas-generator`).
|
||||
2. Eine Swagger UI (nur in Dev) soll unter `/api/docs` erreichbar sein und die erzeugte Spec anzeigen.
|
||||
2. Eine Swagger UI (nur in Dev) soll unter `/api/docs/` erreichbar sein und die erzeugte Spec anzeigen.
|
||||
3. Automatisch erkannte Endpunkte müssen sichtbar sein; für komplexe Fälle (multipart Uploads) sollen einfache Hints / Overrides möglich sein.
|
||||
4. Keine Breaking Changes am Produktions‑Startverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per opt‑in env var.
|
||||
5. Erzeugte Spec soll ins Repo (z. B. `docs/openapi.json`) optional geschrieben werden können (für CI/Review).
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
## Minimaler Scope (MVP)
|
||||
- Dev‑only Integration: Generator installiert und beim Start einmal ausgeführt.
|
||||
- Swagger UI unter `/api/docs` mit generierter Spec.
|
||||
- Swagger UI unter `/api/docs/` mit generierter Spec.
|
||||
- Kurze Anleitung im `README.dev.md` wie man die Doku lokal öffnet.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen:
|
||||
|
||||
- **Authentication**: Alle Admin-Endpoints benötigen jetzt Bearer Token
|
||||
- **Authentication**: Admin-Endpoints laufen jetzt über serverseitige Sessions + CSRF Tokens
|
||||
- **Route-Struktur**: Einige Pfade haben sich geändert (Single Source of Truth: `routeMappings.js`)
|
||||
- **Error Handling**: Neue HTTP-Status-Codes (403 für Auth-Fehler)
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ docker compose -f docker/dev/docker-compose.yml up -d
|
|||
- **Backend**: http://localhost:5001 (API)
|
||||
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
|
||||
- **Slideshow**: http://localhost:3000/slideshow
|
||||
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
|
||||
- **Moderation**: http://localhost:3000/moderation (Login über Admin Session)
|
||||
|
||||
### Logs verfolgen
|
||||
```bash
|
||||
|
|
@ -55,7 +55,7 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
|
|||
### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich
|
||||
|
||||
**Massive API-Änderungen im November 2025:**
|
||||
- Bearer Token Authentication für alle Admin-Endpoints
|
||||
- Session + CSRF Authentication für alle Admin-Endpoints
|
||||
- Route-Pfade umstrukturiert (siehe `routeMappings.js`)
|
||||
- Neue Error-Response-Formate
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ Siehe auch: **`backend/src/routes/README.md`** für vollständige API-Übersicht
|
|||
**Wichtige Route-Gruppen:**
|
||||
- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints
|
||||
- `/api/manage/:token` - Self-Service Management Portal (UUID-Token)
|
||||
- `/api/admin/*` - Admin-Endpoints (Bearer Token Authentication)
|
||||
- `/api/admin/*` - Admin-Endpoints (Session + CSRF Authentication)
|
||||
- `/api/system/migration/*` - Datenbank-Migrationen
|
||||
|
||||
**⚠️ Express Route-Reihenfolge beachten:**
|
||||
|
|
@ -91,13 +91,27 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
|
|||
|
||||
**Zwei Auth-Systeme parallel:**
|
||||
|
||||
1. **Admin API (Bearer Token)**:
|
||||
1. **Admin API (Session + CSRF)**:
|
||||
```bash
|
||||
# .env konfigurieren:
|
||||
ADMIN_API_KEY=your-secure-key-here
|
||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# API-Aufrufe:
|
||||
curl -H "Authorization: Bearer your-secure-key-here" \
|
||||
# Initialen Admin anlegen (falls benötigt)
|
||||
curl -c cookies.txt http://localhost:5001/auth/setup/status
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123"}' \
|
||||
http://localhost:5001/auth/setup/initial-admin
|
||||
|
||||
# Login + CSRF Token holen
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-c cookies.txt -b cookies.txt \
|
||||
-d '{"username":"admin","password":"SuperSicher123"}' \
|
||||
http://localhost:5001/auth/login
|
||||
CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')
|
||||
|
||||
# Authentifizierter Admin-Request
|
||||
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
||||
http://localhost:5001/api/admin/groups
|
||||
```
|
||||
|
||||
|
|
@ -109,13 +123,22 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
|
|||
|
||||
📖 **Vollständige Doku**: `AUTHENTICATION.md`
|
||||
|
||||
#### Admin-Hinweise: Logout & neue Nutzer
|
||||
|
||||
- **Logout:** Bis ein eigener Button im UI existiert, kann die Session jederzeit über den vorhandenen Endpoint beendet werden, z. B. in der Browser-Konsole:
|
||||
```js
|
||||
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
```
|
||||
Alternativ per CLI: `curl -b cookies.txt -X POST http://localhost:5001/auth/logout`. Danach ist das `sid`-Cookie entfernt und die Moderationsseite zeigt wieder den Login.
|
||||
- **Weiterer Admin:** `npm run create-admin -- --username zweiteradmin --password 'SuperPasswort123!' [--role admin --require-password-change]` oder alternativ `./scripts/create_admin_user.sh --username zweiteradmin --password 'SuperPasswort123!' [...]` ruft das Skript (`backend/src/scripts/createAdminUser.js`) auf und legt einen weiteren User an. Das Skript prüft Duplikate, nutzt dieselben Bcrypt-Runden wie das Backend und kann bei Bedarf weiterhin über die DB nachvollzogen werden. Falls du lieber manuell arbeitest, kannst du wie bisher einen Hash erzeugen und direkt in `admin_users` einfügen.
|
||||
|
||||
### OpenAPI-Spezifikation
|
||||
|
||||
Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert:
|
||||
|
||||
```bash
|
||||
# Generiert: backend/docs/openapi.json
|
||||
# Swagger UI: http://localhost:5001/api/docs
|
||||
# Swagger UI: http://localhost:5001/api/docs/
|
||||
|
||||
# Manuelle Generierung:
|
||||
cd backend
|
||||
|
|
@ -157,7 +180,8 @@ router.get('/example', async (req, res) => {
|
|||
- `repositories/GroupRepository.js` - Consent-Management & CRUD
|
||||
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
|
||||
- `routes/batchUpload.js` - Upload mit Consent-Validierung
|
||||
- `middlewares/auth.js` - Admin Authentication (Bearer Token)
|
||||
- `middlewares/session.js` - Express-Session + SQLite Store
|
||||
- `middlewares/auth.js` - Admin Session-Guard & CSRF-Pflicht
|
||||
- `database/DatabaseManager.js` - Automatische Migrationen
|
||||
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik
|
||||
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -32,13 +32,13 @@ This project extends the original [Image-Uploader by vallezw](https://github.com
|
|||
- Test execution time: ~10 seconds for full suite
|
||||
- CI/CD ready with proper teardown and cleanup
|
||||
|
||||
- **🔒 Admin API Authentication** (Nov 16):
|
||||
- Bearer token authentication for all admin endpoints
|
||||
- Secure ADMIN_API_KEY environment variable configuration
|
||||
- **🔒 Admin Session Authentication** (Nov 16):
|
||||
- Server-managed HTTP sessions for all admin/system endpoints
|
||||
- CSRF protection on every mutating request via `X-CSRF-Token`
|
||||
- Secure `ADMIN_SESSION_SECRET` configuration keeps cookies tamper-proof
|
||||
- Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
|
||||
- 403 Forbidden responses for missing/invalid tokens
|
||||
- Session-aware moderation UI with login + first-admin setup wizard
|
||||
- Complete authentication documentation in `AUTHENTICATION.md`
|
||||
- Ready for production deployment with token rotation support
|
||||
|
||||
- **📋 API Route Documentation** (Nov 16):
|
||||
- Single Source of Truth: `backend/src/routes/routeMappings.js`
|
||||
|
|
@ -206,11 +206,12 @@ The application automatically generates optimized preview thumbnails for all upl
|
|||
|
||||
### Moderation Interface (Protected)
|
||||
|
||||
- **Access**: `http://localhost/moderation` (requires authentication)
|
||||
- **Authentication Methods**:
|
||||
- **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
|
||||
- **API Direct Access**: Bearer Token via `Authorization: Bearer <ADMIN_API_KEY>` header
|
||||
- See `AUTHENTICATION.md` for detailed setup instructions
|
||||
- **Access**: `http://localhost/moderation` (requires admin session)
|
||||
- **Authentication Flow**:
|
||||
- Built-in login form establishes a server session stored in HttpOnly cookies
|
||||
- First-time setup wizard creates the initial admin user once `ADMIN_SESSION_SECRET` is configured
|
||||
- CSRF token must be included (header `X-CSRF-Token`) for any mutating admin API call
|
||||
- `AUTHENTICATION.md` documents CLI/cURL examples for managing sessions and CSRF tokens
|
||||
- **Protected Endpoints**: All `/api/admin/*` routes require authentication
|
||||
- **Features**:
|
||||
- Review pending image groups before public display
|
||||
|
|
|
|||
16
TODO.md
16
TODO.md
|
|
@ -66,7 +66,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
|
|||
[x] In der angezeigten Gruppen soll statt Bilder ansehen Gruppe editieren stehen
|
||||
[x] Diese bestehende Ansicht (Bilder ansehen) soll als eigene Seite implementiert werden
|
||||
[x] Ergänzung der Möglichkeit eine Beschreibung zu den Bildern hinzuzufügen
|
||||
[ ] Erweiterung der ModerationPage um reine Datenbankeditor der sqlite Datenbank.
|
||||
|
||||
|
||||
|
||||
## 🚀 Deployment-Überlegungen
|
||||
|
|
@ -98,16 +98,16 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
|
|||
- ✅ Mobile-Kompatibilität
|
||||
|
||||
### Nice-to-Have
|
||||
- 🎨 Drag & Drop Reihenfolge ändern
|
||||
- 📊 Upload-Progress mit Details
|
||||
- 🖼️ Thumbnail-Navigation in Slideshow
|
||||
- 🔄 Batch-Operations (alle entfernen, etc.)
|
||||
[x] 🎨 Drag & Drop Reihenfolge ändern
|
||||
[x] 📊 Upload-Progress mit Details
|
||||
[x] 🖼️ Thumbnail-Navigation in Slideshow
|
||||
[ ] 🔄 Batch-Operations (alle entfernen, etc.)
|
||||
|
||||
### Future Features
|
||||
- 👤 User-Management
|
||||
- 🏷️ Tagging-System
|
||||
- 📤 Export-Funktionen
|
||||
- 🎵 Audio-Integration
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
# Backend Environment Variables
|
||||
# Copy this file to .env and adjust values for local development
|
||||
|
||||
# Whether to remove images when starting the server (cleanup)
|
||||
REMOVE_IMAGES=false
|
||||
|
||||
# Node.js environment (development, production, test)
|
||||
NODE_ENV=development
|
||||
|
||||
# Port for the backend server
|
||||
PORT=5000
|
||||
|
||||
# Admin API Authentication
|
||||
# Generate a secure random string for production!
|
||||
# Example: openssl rand -hex 32
|
||||
ADMIN_API_KEY=your-secret-admin-key-change-me-in-production
|
||||
|
||||
# Database settings (if needed in future)
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=3306
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
coverageDirectory: 'coverage',
|
||||
setupFiles: ['<rootDir>/tests/env.js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'!src/index.js', // Server startup
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"server": "nodemon src/index.js",
|
||||
"server": "nodemon --ignore docs/openapi.json src/index.js",
|
||||
"client": "npm run dev --prefix ../frontend",
|
||||
"client-build": "cd ../frontend && npm run build && serve -s build -l 80",
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
|
|
@ -15,15 +15,19 @@
|
|||
"validate-openapi": "redocly lint docs/openapi.json",
|
||||
"test": "jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"test:api": "jest tests/api"
|
||||
"test:api": "jest tests/api",
|
||||
"create-admin": "node src/scripts/createAdminUser.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"connect-sqlite3": "^0.9.16",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-session": "^1.18.2",
|
||||
"find-remove": "^2.0.3",
|
||||
"fs": "^0.0.1-security",
|
||||
"node-cron": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -5,18 +5,22 @@ const fs = require('fs');
|
|||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
// Use in-memory database for tests, file-based for production
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
this.dbPath = ':memory:';
|
||||
} else {
|
||||
// Place database file under data/db
|
||||
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
|
||||
}
|
||||
this.dbPath = null;
|
||||
this.schemaPath = path.join(__dirname, 'schema.sql');
|
||||
}
|
||||
|
||||
getDatabasePath() {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return ':memory:';
|
||||
}
|
||||
return path.join(__dirname, '../data/db/image_uploader.db');
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
if (!this.dbPath) {
|
||||
this.dbPath = this.getDatabasePath();
|
||||
}
|
||||
// Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
|
||||
if (this.dbPath !== ':memory:') {
|
||||
const dataDir = path.dirname(this.dbPath);
|
||||
|
|
@ -47,8 +51,10 @@ class DatabaseManager {
|
|||
// Run database migrations (automatic on startup)
|
||||
await this.runMigrations();
|
||||
|
||||
// Generate missing previews for existing images (skip in test mode)
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const skipPreviewGeneration = ['1', 'true', 'yes'].includes(String(process.env.SKIP_PREVIEW_GENERATION || '').toLowerCase());
|
||||
|
||||
// Generate missing previews for existing images (skip in test mode or when explicitly disabled)
|
||||
if (process.env.NODE_ENV !== 'test' && !skipPreviewGeneration) {
|
||||
await this.generateMissingPreviews();
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +174,31 @@ class DatabaseManager {
|
|||
`);
|
||||
console.log('✓ Trigger erstellt');
|
||||
|
||||
// Admin Users Tabelle (für Session-Authentication)
|
||||
await this.run(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await this.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username)');
|
||||
await this.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
|
||||
AFTER UPDATE ON admin_users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`);
|
||||
console.log('✓ Admin Users Tabelle erstellt');
|
||||
|
||||
console.log('✅ Datenbank-Schema vollständig erstellt');
|
||||
} catch (error) {
|
||||
console.error('💥 Fehler beim Erstellen des Schemas:', error);
|
||||
|
|
@ -188,6 +219,19 @@ class DatabaseManager {
|
|||
});
|
||||
}
|
||||
|
||||
// Execute multi-statement SQL scripts (z. B. Migrationen mit Triggern)
|
||||
exec(sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.exec(sql, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Promise-wrapper für sqlite3.get
|
||||
get(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -373,29 +417,27 @@ class DatabaseManager {
|
|||
// Execute migration in a transaction
|
||||
await this.run('BEGIN TRANSACTION');
|
||||
|
||||
// Remove comments (both line and inline) before splitting
|
||||
// Remove comments (both line and inline) to avoid sqlite parser issues
|
||||
const cleanedSql = sql
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
// Remove inline comments (everything after --)
|
||||
const commentIndex = line.indexOf('--');
|
||||
if (commentIndex !== -1) {
|
||||
return line.substring(0, commentIndex);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
// Split by semicolon and execute each statement
|
||||
const statements = cleanedSql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
await this.run(statement);
|
||||
if (!cleanedSql) {
|
||||
console.warn(` ⚠️ Migration ${file} enthält keinen ausführbaren SQL-Code, übersprungen`);
|
||||
await this.run('COMMIT');
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.exec(cleanedSql);
|
||||
|
||||
// Record migration
|
||||
await this.run(
|
||||
'INSERT INTO schema_migrations (migration_name) VALUES (?)',
|
||||
|
|
|
|||
21
backend/src/database/migrations/008_create_admin_users.sql
Normal file
21
backend/src/database/migrations/008_create_admin_users.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Migration: Create admin_users table for server-side admin authentication
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
|
||||
AFTER UPDATE ON admin_users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
|
@ -48,3 +48,25 @@ FOR EACH ROW
|
|||
BEGIN
|
||||
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- Admin Users Tabelle zur Verwaltung von Backend-Admins
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
|
||||
AFTER UPDATE ON admin_users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
|
@ -1,49 +1,19 @@
|
|||
/**
|
||||
* Admin Authentication Middleware
|
||||
* Validates Bearer token from Authorization header against ADMIN_API_KEY env variable
|
||||
* Validates server-side session for admin users
|
||||
*/
|
||||
|
||||
const requireAdminAuth = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const sessionUser = req.session && req.session.user;
|
||||
|
||||
// Check if Authorization header exists
|
||||
if (!authHeader) {
|
||||
if (!sessionUser || sessionUser.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
error: 'Zugriff verweigert',
|
||||
message: 'Authorization header fehlt'
|
||||
reason: 'SESSION_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a Bearer token
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return res.status(403).json({
|
||||
error: 'Zugriff verweigert',
|
||||
message: 'Ungültiges Authorization Format. Erwartet: Bearer <token>'
|
||||
});
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
||||
// Check if ADMIN_API_KEY is configured
|
||||
if (!adminKey) {
|
||||
console.error('⚠️ ADMIN_API_KEY nicht in .env konfiguriert!');
|
||||
return res.status(500).json({
|
||||
error: 'Server-Konfigurationsfehler',
|
||||
message: 'Admin-Authentifizierung nicht konfiguriert'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if (token !== adminKey) {
|
||||
return res.status(403).json({
|
||||
error: 'Zugriff verweigert',
|
||||
message: 'Ungültiger Admin-Token'
|
||||
});
|
||||
}
|
||||
|
||||
// Token valid, proceed to route
|
||||
res.locals.adminUser = sessionUser;
|
||||
next();
|
||||
};
|
||||
|
||||
|
|
|
|||
40
backend/src/middlewares/csrf.js
Normal file
40
backend/src/middlewares/csrf.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
const requireCsrf = (req, res, next) => {
|
||||
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.user) {
|
||||
return res.status(403).json({
|
||||
error: 'Zugriff verweigert',
|
||||
reason: 'SESSION_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.session.csrfToken) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF erforderlich',
|
||||
reason: 'CSRF_SESSION_MISSING'
|
||||
});
|
||||
}
|
||||
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
if (!headerToken) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF erforderlich',
|
||||
reason: 'CSRF_HEADER_MISSING'
|
||||
});
|
||||
}
|
||||
|
||||
if (headerToken !== req.session.csrfToken) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF ungültig',
|
||||
reason: 'CSRF_TOKEN_INVALID'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { requireCsrf };
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
const express = require("express");
|
||||
const fileUpload = require("express-fileupload");
|
||||
const cors = require("./cors");
|
||||
const session = require("./session");
|
||||
|
||||
const applyMiddlewares = (app) => {
|
||||
app.use(fileUpload());
|
||||
app.use(cors);
|
||||
app.use(session);
|
||||
// JSON Parser für PATCH/POST Requests
|
||||
app.use(express.json());
|
||||
};
|
||||
|
|
|
|||
42
backend/src/middlewares/session.js
Normal file
42
backend/src/middlewares/session.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const session = require('express-session');
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
|
||||
const SESSION_FILENAME = process.env.ADMIN_SESSION_DB || 'sessions.sqlite';
|
||||
const SESSION_DIR = process.env.ADMIN_SESSION_DIR
|
||||
? path.resolve(process.env.ADMIN_SESSION_DIR)
|
||||
: path.join(__dirname, '..', 'data');
|
||||
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET;
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (!SESSION_SECRET) {
|
||||
throw new Error('ADMIN_SESSION_SECRET is required for session management');
|
||||
}
|
||||
|
||||
// Ensure session directory exists so SQLite can create the DB file
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const store = new SQLiteStore({
|
||||
db: SESSION_FILENAME,
|
||||
dir: SESSION_DIR,
|
||||
ttl: 8 * 60 * 60 // seconds
|
||||
});
|
||||
|
||||
const sessionMiddleware = session({
|
||||
name: 'sid',
|
||||
store,
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: IS_PRODUCTION,
|
||||
sameSite: 'strict',
|
||||
maxAge: 8 * 60 * 60 * 1000 // 8 hours
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = sessionMiddleware;
|
||||
67
backend/src/repositories/AdminUserRepository.js
Normal file
67
backend/src/repositories/AdminUserRepository.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const dbManager = require('../database/DatabaseManager');
|
||||
|
||||
class AdminUserRepository {
|
||||
async countActiveAdmins() {
|
||||
const row = await dbManager.get(
|
||||
'SELECT COUNT(*) as count FROM admin_users WHERE is_active = 1'
|
||||
);
|
||||
return row ? row.count : 0;
|
||||
}
|
||||
|
||||
async getByUsername(username) {
|
||||
return dbManager.get(
|
||||
'SELECT * FROM admin_users WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
return dbManager.get(
|
||||
'SELECT * FROM admin_users WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
async listActiveAdmins() {
|
||||
return dbManager.all(
|
||||
`SELECT id, username, role, is_active, requires_password_change, last_login_at, created_at, updated_at
|
||||
FROM admin_users
|
||||
WHERE is_active = 1
|
||||
ORDER BY username ASC`
|
||||
);
|
||||
}
|
||||
|
||||
async createAdminUser({ username, passwordHash, role = 'admin', requiresPasswordChange = false }) {
|
||||
const result = await dbManager.run(
|
||||
`INSERT INTO admin_users (username, password_hash, role, requires_password_change)
|
||||
VALUES (?, ?, ?, ?)` ,
|
||||
[username, passwordHash, role, requiresPasswordChange ? 1 : 0]
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async updatePassword(id, newPasswordHash, requiresPasswordChange = false) {
|
||||
await dbManager.run(
|
||||
`UPDATE admin_users
|
||||
SET password_hash = ?, requires_password_change = ?
|
||||
WHERE id = ?`,
|
||||
[newPasswordHash, requiresPasswordChange ? 1 : 0, id]
|
||||
);
|
||||
}
|
||||
|
||||
async markInactive(id) {
|
||||
await dbManager.run(
|
||||
'UPDATE admin_users SET is_active = 0 WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
async recordSuccessfulLogin(id) {
|
||||
await dbManager.run(
|
||||
'UPDATE admin_users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AdminUserRepository();
|
||||
|
|
@ -114,7 +114,7 @@ npm run generate-openapi
|
|||
|
||||
**Generiert:** `backend/docs/openapi.json`
|
||||
|
||||
**Zugriff:** http://localhost:5000/api/docs (nur dev-mode)
|
||||
**Zugriff:** http://localhost:5001/api/docs/ (nur dev-mode)
|
||||
|
||||
### Was wird generiert?
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ npm run test-openapi
|
|||
### Swagger UI öffnen
|
||||
|
||||
```
|
||||
http://localhost:5000/api/docs
|
||||
http://localhost:5001/api/docs/
|
||||
```
|
||||
|
||||
**Hinweis:** Nur im Development-Modus verfügbar!
|
||||
|
|
|
|||
|
|
@ -4,14 +4,77 @@ const DeletionLogRepository = require('../repositories/DeletionLogRepository');
|
|||
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
|
||||
const GroupRepository = require('../repositories/GroupRepository');
|
||||
const GroupCleanupService = require('../services/GroupCleanupService');
|
||||
const AdminAuthService = require('../services/AdminAuthService');
|
||||
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
|
||||
const { requireAdminAuth } = require('../middlewares/auth');
|
||||
const { requireCsrf } = require('../middlewares/csrf');
|
||||
|
||||
// GroupCleanupService ist bereits eine Instanz, keine Klasse
|
||||
const cleanupService = GroupCleanupService;
|
||||
|
||||
// Apply admin authentication to ALL routes in this router
|
||||
router.use(requireAdminAuth);
|
||||
router.use(requireCsrf);
|
||||
|
||||
router.post('/users', async (req, res) => {
|
||||
/*
|
||||
#swagger.tags = ['Admin - Users']
|
||||
#swagger.summary = 'Create a new admin user'
|
||||
#swagger.description = 'Adds an additional admin (or auditor) via API'
|
||||
#swagger.requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string', example: 'admin2' },
|
||||
password: { type: 'string', example: 'SehrSicher123!' },
|
||||
role: { type: 'string', example: 'admin' },
|
||||
requirePasswordChange: { type: 'boolean', example: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#swagger.responses[201] = {
|
||||
description: 'Admin user created',
|
||||
schema: {
|
||||
success: true,
|
||||
user: {
|
||||
id: 5,
|
||||
username: 'admin2',
|
||||
role: 'admin',
|
||||
requiresPasswordChange: false
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
try {
|
||||
const { username, password, role, requirePasswordChange } = req.body || {};
|
||||
const user = await AdminAuthService.createAdminUser({
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
requiresPasswordChange: Boolean(requirePasswordChange)
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin API] create user failed:', error.message);
|
||||
if (['USERNAME_REQUIRED', 'PASSWORD_TOO_WEAK'].includes(error.message)) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error.message === 'USERNAME_IN_USE') {
|
||||
return res.status(409).json({ error: 'USERNAME_IN_USE' });
|
||||
}
|
||||
res.status(500).json({ error: 'CREATE_ADMIN_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/deletion-log', async (req, res) => {
|
||||
/*
|
||||
|
|
|
|||
165
backend/src/routes/auth.js
Normal file
165
backend/src/routes/auth.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AdminAuthService = require('../services/AdminAuthService');
|
||||
const { requireAdminAuth } = require('../middlewares/auth');
|
||||
const { requireCsrf } = require('../middlewares/csrf');
|
||||
|
||||
router.get('/setup/status', async (req, res) => {
|
||||
try {
|
||||
const needsSetup = await AdminAuthService.needsInitialSetup();
|
||||
const sessionUser = req.session && req.session.user
|
||||
? {
|
||||
id: req.session.user.id,
|
||||
username: req.session.user.username,
|
||||
role: req.session.user.role,
|
||||
requiresPasswordChange: Boolean(req.session.user.requiresPasswordChange)
|
||||
}
|
||||
: null;
|
||||
res.json({
|
||||
needsSetup,
|
||||
hasSession: Boolean(sessionUser),
|
||||
user: sessionUser
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] setup/status error:', error);
|
||||
res.status(500).json({ error: 'SETUP_STATUS_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/setup/initial-admin', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'USERNAME_AND_PASSWORD_REQUIRED' });
|
||||
}
|
||||
|
||||
const user = await AdminAuthService.createInitialAdmin({ username, password });
|
||||
const csrfToken = AdminAuthService.startSession(req, {
|
||||
...user,
|
||||
requiresPasswordChange: false
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
},
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] initial setup error:', error.message);
|
||||
switch (error.message) {
|
||||
case 'SETUP_ALREADY_COMPLETED':
|
||||
return res.status(409).json({ error: 'SETUP_ALREADY_COMPLETED' });
|
||||
case 'USERNAME_REQUIRED':
|
||||
return res.status(400).json({ error: 'USERNAME_REQUIRED' });
|
||||
case 'PASSWORD_TOO_WEAK':
|
||||
return res.status(400).json({ error: 'PASSWORD_TOO_WEAK' });
|
||||
default:
|
||||
if (error.message && error.message.includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: 'USERNAME_IN_USE' });
|
||||
}
|
||||
return res.status(500).json({ error: 'INITIAL_SETUP_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'USERNAME_AND_PASSWORD_REQUIRED' });
|
||||
}
|
||||
|
||||
if (await AdminAuthService.needsInitialSetup()) {
|
||||
return res.status(409).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const user = await AdminAuthService.verifyCredentials(username, password);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const csrfToken = AdminAuthService.startSession(req, user);
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
requiresPasswordChange: user.requiresPasswordChange
|
||||
},
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] login error:', error);
|
||||
res.status(500).json({ error: 'LOGIN_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
await AdminAuthService.destroySession(req);
|
||||
res.clearCookie('sid');
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('[Auth] logout error:', error);
|
||||
res.status(500).json({ error: 'LOGOUT_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/csrf-token', requireAdminAuth, (req, res) => {
|
||||
if (!req.session.csrfToken) {
|
||||
req.session.csrfToken = AdminAuthService.generateCsrfToken();
|
||||
}
|
||||
|
||||
res.json({ csrfToken: req.session.csrfToken });
|
||||
});
|
||||
|
||||
router.post('/change-password', requireAdminAuth, requireCsrf, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'CURRENT_AND_NEW_PASSWORD_REQUIRED' });
|
||||
}
|
||||
|
||||
const user = await AdminAuthService.changePassword({
|
||||
userId: req.session.user.id,
|
||||
currentPassword,
|
||||
newPassword
|
||||
});
|
||||
|
||||
req.session.user = {
|
||||
...req.session.user,
|
||||
requiresPasswordChange: false
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
requiresPasswordChange: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] change password error:', error.message || error);
|
||||
switch (error.message) {
|
||||
case 'CURRENT_PASSWORD_REQUIRED':
|
||||
return res.status(400).json({ error: 'CURRENT_PASSWORD_REQUIRED' });
|
||||
case 'PASSWORD_TOO_WEAK':
|
||||
return res.status(400).json({ error: 'PASSWORD_TOO_WEAK' });
|
||||
case 'INVALID_CURRENT_PASSWORD':
|
||||
return res.status(400).json({ error: 'INVALID_CURRENT_PASSWORD' });
|
||||
case 'USER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||
default:
|
||||
return res.status(500).json({ error: 'PASSWORD_CHANGE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -10,9 +10,11 @@ const GroupRepository = require('../repositories/GroupRepository');
|
|||
const SocialMediaRepository = require('../repositories/SocialMediaRepository');
|
||||
const dbManager = require('../database/DatabaseManager');
|
||||
const { requireAdminAuth } = require('../middlewares/auth');
|
||||
const { requireCsrf } = require('../middlewares/csrf');
|
||||
|
||||
// Schütze alle Consent-Routes mit Admin-Auth
|
||||
router.use(requireAdminAuth);
|
||||
router.use(requireCsrf);
|
||||
|
||||
// ============================================================================
|
||||
// Social Media Platforms
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
const authRouter = require('./auth');
|
||||
const uploadRouter = require('./upload');
|
||||
const downloadRouter = require('./download');
|
||||
const batchUploadRouter = require('./batchUpload');
|
||||
const groupsRouter = require('./groups');
|
||||
const socialMediaRouter = require('./socialMedia');
|
||||
const migrationRouter = require('./migration');
|
||||
const reorderRouter = require('./reorder');
|
||||
const adminRouter = require('./admin');
|
||||
|
|
@ -13,10 +15,12 @@ const routeMappingsConfig = require('./routeMappings');
|
|||
|
||||
// Map router names to actual router instances
|
||||
const routerMap = {
|
||||
auth: authRouter,
|
||||
upload: uploadRouter,
|
||||
download: downloadRouter,
|
||||
batchUpload: batchUploadRouter,
|
||||
groups: groupsRouter,
|
||||
socialMedia: socialMediaRouter,
|
||||
migration: migrationRouter,
|
||||
reorder: reorderRouter,
|
||||
admin: adminRouter,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const { Router } = require('express');
|
|||
const MigrationService = require('../services/MigrationService');
|
||||
const dbManager = require('../database/DatabaseManager');
|
||||
const { requireAdminAuth } = require('../middlewares/auth');
|
||||
const { requireCsrf } = require('../middlewares/csrf');
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ router.get('/status', async (req, res) => {
|
|||
});
|
||||
|
||||
// Protect dangerous migration operations with admin auth
|
||||
router.post('/migrate', requireAdminAuth, async (req, res) => {
|
||||
router.post('/migrate', requireAdminAuth, requireCsrf, async (req, res) => {
|
||||
/*
|
||||
#swagger.tags = ['System Migration']
|
||||
#swagger.summary = 'Manually trigger migration'
|
||||
|
|
@ -66,7 +67,7 @@ router.post('/migrate', requireAdminAuth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/rollback', requireAdminAuth, async (req, res) => {
|
||||
router.post('/rollback', requireAdminAuth, requireCsrf, async (req, res) => {
|
||||
/*
|
||||
#swagger.tags = ['System Migration']
|
||||
#swagger.summary = 'Rollback to JSON'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const GroupRepository = require('../repositories/GroupRepository');
|
||||
const { requireAdminAuth } = require('../middlewares/auth');
|
||||
const { requireCsrf } = require('../middlewares/csrf');
|
||||
|
||||
router.use(requireAdminAuth);
|
||||
router.use(requireCsrf);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@
|
|||
*/
|
||||
|
||||
module.exports = [
|
||||
// Auth API - Session & CSRF Management
|
||||
{ router: 'auth', prefix: '/auth', file: 'auth.js' },
|
||||
|
||||
// Public API - Öffentlich zugänglich
|
||||
{ router: 'upload', prefix: '/api', file: 'upload.js' },
|
||||
{ router: 'download', prefix: '/api', file: 'download.js' },
|
||||
{ router: 'batchUpload', prefix: '/api', file: 'batchUpload.js' },
|
||||
{ router: 'groups', prefix: '/api', file: 'groups.js' },
|
||||
{ router: 'socialMedia', prefix: '/api', file: 'socialMedia.js' },
|
||||
|
||||
// Management API - Token-basierter Zugriff
|
||||
{ router: 'management', prefix: '/api/manage', file: 'management.js' },
|
||||
|
|
|
|||
24
backend/src/routes/socialMedia.js
Normal file
24
backend/src/routes/socialMedia.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const express = require('express');
|
||||
const SocialMediaRepository = require('../repositories/SocialMediaRepository');
|
||||
const dbManager = require('../database/DatabaseManager');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Public endpoint: list active social media platforms for consent selection
|
||||
*/
|
||||
router.get('/social-media/platforms', async (req, res) => {
|
||||
try {
|
||||
const socialMediaRepo = new SocialMediaRepository(dbManager);
|
||||
const platforms = await socialMediaRepo.getActivePlatforms();
|
||||
res.json(platforms);
|
||||
} catch (error) {
|
||||
console.error('[SOCIAL_MEDIA] Failed to fetch platforms:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch social media platforms',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
102
backend/src/scripts/createAdminUser.js
Normal file
102
backend/src/scripts/createAdminUser.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const dbManager = require('../database/DatabaseManager');
|
||||
const AdminUserRepository = require('../repositories/AdminUserRepository');
|
||||
|
||||
const DEFAULT_SALT_ROUNDS = parseInt(process.env.ADMIN_PASSWORD_SALT_ROUNDS || '12', 10);
|
||||
|
||||
const printUsage = () => {
|
||||
console.log('Usage: node src/scripts/createAdminUser.js --username <name> --password <pass> [--role <role>] [--require-password-change]');
|
||||
console.log('Example: npm run create-admin -- --username admin2 --password "SehrSicher123!"');
|
||||
};
|
||||
|
||||
const parseArgs = () => {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const args = {};
|
||||
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const arg = rawArgs[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
const next = rawArgs[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const validateInput = ({ username, password }) => {
|
||||
if (!username || !username.trim()) {
|
||||
throw new Error('USERNAME_REQUIRED');
|
||||
}
|
||||
|
||||
if (!password || password.length < 10) {
|
||||
throw new Error('PASSWORD_TOO_WEAK');
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.help || args.h) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
validateInput(args);
|
||||
} catch (validationError) {
|
||||
console.error('⚠️ Validation error:', validationError.message);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const normalizedUsername = args.username.trim().toLowerCase();
|
||||
const role = args.role || 'admin';
|
||||
const requirePasswordChange = Boolean(args['require-password-change']);
|
||||
|
||||
// Skip expensive preview generation for CLI usage
|
||||
process.env.SKIP_PREVIEW_GENERATION = process.env.SKIP_PREVIEW_GENERATION || '1';
|
||||
|
||||
try {
|
||||
await dbManager.initialize();
|
||||
|
||||
const existingUser = await AdminUserRepository.getByUsername(normalizedUsername);
|
||||
if (existingUser) {
|
||||
console.error(`❌ Benutzer '${normalizedUsername}' existiert bereits.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(args.password, DEFAULT_SALT_ROUNDS);
|
||||
const id = await AdminUserRepository.createAdminUser({
|
||||
username: normalizedUsername,
|
||||
passwordHash,
|
||||
role,
|
||||
requiresPasswordChange: requirePasswordChange
|
||||
});
|
||||
|
||||
console.log('✅ Admin-Benutzer angelegt:');
|
||||
console.log(` ID: ${id}`);
|
||||
console.log(` Username: ${normalizedUsername}`);
|
||||
console.log(` Rolle: ${role}`);
|
||||
console.log(` Passwort-Änderung erforderlich: ${requirePasswordChange}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Anlegen des Admin-Benutzers:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
try {
|
||||
await dbManager.close();
|
||||
} catch (closeError) {
|
||||
console.warn('⚠️ Datenbank konnte nicht sauber geschlossen werden:', closeError.message);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,29 +1,17 @@
|
|||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const initiateResources = require('./utils/initiate-resources');
|
||||
const dbManager = require('./database/DatabaseManager');
|
||||
const SchedulerService = require('./services/SchedulerService');
|
||||
const generateOpenApi = require('./generate-openapi');
|
||||
|
||||
// Dev: Auto-generate OpenAPI spec on server start (skip in test mode)
|
||||
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
|
||||
try {
|
||||
console.log('🔄 Generating OpenAPI specification...');
|
||||
require('./generate-openapi');
|
||||
console.log('✓ OpenAPI spec generated');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to generate OpenAPI spec:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Dev: Swagger UI (mount only in non-production)
|
||||
let swaggerUi, swaggerDocument;
|
||||
// Dev: Swagger UI (mount only in non-production) — require lazily
|
||||
let swaggerUi = null;
|
||||
try {
|
||||
// require lazily — only available/used in dev
|
||||
swaggerUi = require('swagger-ui-express');
|
||||
swaggerDocument = require('../docs/openapi.json');
|
||||
} catch (e) {
|
||||
// ignore if not installed or file missing
|
||||
swaggerUi = null;
|
||||
swaggerDocument = null;
|
||||
}
|
||||
|
||||
class Server {
|
||||
|
|
@ -35,8 +23,35 @@ class Server {
|
|||
this._app = express();
|
||||
}
|
||||
|
||||
async generateOpenApiSpecIfNeeded() {
|
||||
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 Generating OpenAPI specification...');
|
||||
await generateOpenApi();
|
||||
console.log('✓ OpenAPI spec generated');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to generate OpenAPI spec:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
loadSwaggerDocument() {
|
||||
try {
|
||||
const specPath = path.join(__dirname, '..', 'docs', 'openapi.json');
|
||||
const raw = fs.readFileSync(specPath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Unable to load Swagger document:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
await this.generateOpenApiSpecIfNeeded();
|
||||
|
||||
// Initialisiere Datenbank
|
||||
console.log('🔄 Initialisiere Datenbank...');
|
||||
await dbManager.initialize();
|
||||
|
|
@ -48,10 +63,13 @@ class Server {
|
|||
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
|
||||
|
||||
// Mount Swagger UI in dev only when available
|
||||
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
|
||||
if (process.env.NODE_ENV !== 'production' && swaggerUi) {
|
||||
const swaggerDocument = this.loadSwaggerDocument();
|
||||
if (swaggerDocument) {
|
||||
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
console.log('ℹ️ Swagger UI mounted at /api/docs (dev only)');
|
||||
}
|
||||
}
|
||||
this._app.listen(this._port, () => {
|
||||
console.log(`✅ Server läuft auf Port ${this._port}`);
|
||||
console.log(`📊 SQLite Datenbank aktiv`);
|
||||
|
|
|
|||
164
backend/src/services/AdminAuthService.js
Normal file
164
backend/src/services/AdminAuthService.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const AdminUserRepository = require('../repositories/AdminUserRepository');
|
||||
|
||||
const DEFAULT_SALT_ROUNDS = parseInt(process.env.ADMIN_PASSWORD_SALT_ROUNDS || '12', 10);
|
||||
|
||||
class AdminAuthService {
|
||||
async needsInitialSetup() {
|
||||
const count = await AdminUserRepository.countActiveAdmins();
|
||||
return count === 0;
|
||||
}
|
||||
|
||||
async createInitialAdmin({ username, password }) {
|
||||
const trimmedUsername = (username || '').trim().toLowerCase();
|
||||
if (!trimmedUsername) {
|
||||
throw new Error('USERNAME_REQUIRED');
|
||||
}
|
||||
if (!password || password.length < 10) {
|
||||
throw new Error('PASSWORD_TOO_WEAK');
|
||||
}
|
||||
|
||||
const needsSetup = await this.needsInitialSetup();
|
||||
if (!needsSetup) {
|
||||
throw new Error('SETUP_ALREADY_COMPLETED');
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
const id = await AdminUserRepository.createAdminUser({
|
||||
username: trimmedUsername,
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
requiresPasswordChange: false
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
username: trimmedUsername,
|
||||
role: 'admin'
|
||||
};
|
||||
}
|
||||
|
||||
async createAdminUser({ username, password, role = 'admin', requiresPasswordChange = false }) {
|
||||
const trimmedUsername = (username || '').trim().toLowerCase();
|
||||
if (!trimmedUsername) {
|
||||
throw new Error('USERNAME_REQUIRED');
|
||||
}
|
||||
if (!password || password.length < 10) {
|
||||
throw new Error('PASSWORD_TOO_WEAK');
|
||||
}
|
||||
|
||||
const normalizedRole = (role || 'admin').trim().toLowerCase();
|
||||
const targetRole = normalizedRole || 'admin';
|
||||
|
||||
const existing = await AdminUserRepository.getByUsername(trimmedUsername);
|
||||
if (existing) {
|
||||
throw new Error('USERNAME_IN_USE');
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
const id = await AdminUserRepository.createAdminUser({
|
||||
username: trimmedUsername,
|
||||
passwordHash,
|
||||
role: targetRole,
|
||||
requiresPasswordChange
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
username: trimmedUsername,
|
||||
role: targetRole,
|
||||
requiresPasswordChange: Boolean(requiresPasswordChange)
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword({ userId, currentPassword, newPassword }) {
|
||||
if (!userId) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
if (!currentPassword) {
|
||||
throw new Error('CURRENT_PASSWORD_REQUIRED');
|
||||
}
|
||||
if (!newPassword || newPassword.length < 10) {
|
||||
throw new Error('PASSWORD_TOO_WEAK');
|
||||
}
|
||||
|
||||
const userRecord = await AdminUserRepository.getById(userId);
|
||||
if (!userRecord || !userRecord.is_active) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(currentPassword || '', userRecord.password_hash);
|
||||
if (!matches) {
|
||||
throw new Error('INVALID_CURRENT_PASSWORD');
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(newPassword);
|
||||
await AdminUserRepository.updatePassword(userRecord.id, passwordHash, false);
|
||||
|
||||
return {
|
||||
id: userRecord.id,
|
||||
username: userRecord.username,
|
||||
role: userRecord.role,
|
||||
requiresPasswordChange: false
|
||||
};
|
||||
}
|
||||
|
||||
async hashPassword(password) {
|
||||
return bcrypt.hash(password, DEFAULT_SALT_ROUNDS);
|
||||
}
|
||||
|
||||
async verifyCredentials(username, password) {
|
||||
const normalizedUsername = (username || '').trim().toLowerCase();
|
||||
const user = await AdminUserRepository.getByUsername(normalizedUsername);
|
||||
if (!user || !user.is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(password || '', user.password_hash);
|
||||
if (!matches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await AdminUserRepository.recordSuccessfulLogin(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
requiresPasswordChange: Boolean(user.requires_password_change)
|
||||
};
|
||||
}
|
||||
|
||||
generateCsrfToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
startSession(req, user) {
|
||||
const csrfToken = this.generateCsrfToken();
|
||||
req.session.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
requiresPasswordChange: user.requiresPasswordChange || false
|
||||
};
|
||||
req.session.csrfToken = csrfToken;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
async destroySession(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!req.session) {
|
||||
return resolve();
|
||||
}
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AdminAuthService();
|
||||
|
|
@ -1,55 +1,34 @@
|
|||
const { getRequest } = require('../testServer');
|
||||
const { getAdminSession } = require('../utils/adminSession');
|
||||
|
||||
describe('Admin Auth Middleware', () => {
|
||||
describe('Without Auth Token', () => {
|
||||
it('should reject requests without Authorization header', async () => {
|
||||
describe('Without Session', () => {
|
||||
it('should reject requests without session cookie', async () => {
|
||||
const response = await getRequest()
|
||||
.get('/api/admin/deletion-log')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.message).toContain('Authorization header fehlt');
|
||||
expect(response.body).toHaveProperty('reason', 'SESSION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject requests with invalid Bearer format', async () => {
|
||||
const response = await getRequest()
|
||||
describe('With Valid Session', () => {
|
||||
let adminSession;
|
||||
|
||||
beforeAll(async () => {
|
||||
adminSession = await getAdminSession();
|
||||
});
|
||||
|
||||
it('should allow access with valid session', async () => {
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/deletion-log')
|
||||
.set('Authorization', 'InvalidFormat token123')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.message).toContain('Ungültiges Authorization Format');
|
||||
});
|
||||
|
||||
it('should reject requests with wrong token', async () => {
|
||||
const response = await getRequest()
|
||||
.get('/api/admin/deletion-log')
|
||||
.set('Authorization', 'Bearer wrong-token-123')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.message).toContain('Ungültiger Admin-Token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('With Valid Auth Token', () => {
|
||||
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-123';
|
||||
|
||||
beforeAll(() => {
|
||||
// Set test admin key
|
||||
process.env.ADMIN_API_KEY = validToken;
|
||||
});
|
||||
|
||||
it('should allow access with valid Bearer token', async () => {
|
||||
const response = await getRequest()
|
||||
.get('/api/admin/deletion-log')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success');
|
||||
});
|
||||
|
||||
it('should protect all admin endpoints', async () => {
|
||||
it('should allow access to multiple admin endpoints', async () => {
|
||||
const endpoints = [
|
||||
'/api/admin/deletion-log',
|
||||
'/api/admin/rate-limiter/stats',
|
||||
|
|
@ -58,9 +37,8 @@ describe('Admin Auth Middleware', () => {
|
|||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get(endpoint)
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('Admin API - Security', () => {
|
|||
.get('/api/admin/deletion-log')
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body).toHaveProperty('reason', 'SESSION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -57,8 +57,8 @@ describe('Admin API - Security', () => {
|
|||
});
|
||||
|
||||
it('should validate query parameters with authorization', async () => {
|
||||
// This test would need a valid admin token
|
||||
// For now, we just test that invalid params are rejected
|
||||
// This test would require a logged-in admin session
|
||||
// For now, we just ensure the endpoint rejects unauthenticated access
|
||||
await getRequest()
|
||||
.get('/api/admin/groups?status=invalid_status')
|
||||
.expect(403); // Still 403 without auth, but validates endpoint exists
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
const { getRequest } = require('../testServer');
|
||||
const { getAdminSession } = require('../utils/adminSession');
|
||||
|
||||
describe('Consent Management API', () => {
|
||||
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-12345';
|
||||
let adminSession;
|
||||
|
||||
beforeAll(async () => {
|
||||
adminSession = await getAdminSession();
|
||||
});
|
||||
|
||||
describe('GET /api/admin/social-media/platforms', () => {
|
||||
it('should return list of social media platforms', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/social-media/platforms')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
|
|
@ -15,9 +19,8 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should include platform metadata', async () => {
|
||||
const response = await getRequest()
|
||||
.get('/api/admin/social-media/platforms')
|
||||
.set('Authorization', `Bearer ${validToken}`);
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/social-media/platforms');
|
||||
|
||||
if (response.body.length > 0) {
|
||||
const platform = response.body[0];
|
||||
|
|
@ -30,16 +33,14 @@ describe('Consent Management API', () => {
|
|||
|
||||
describe('GET /api/admin/groups/:groupId/consents', () => {
|
||||
it('should return 404 for non-existent group', async () => {
|
||||
await getRequest()
|
||||
await adminSession.agent
|
||||
.get('/api/admin/groups/non-existent-group/consents')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts', async () => {
|
||||
await getRequest()
|
||||
await adminSession.agent
|
||||
.get('/api/admin/groups/../../../etc/passwd/consents')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,9 +54,9 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should require valid consent data with auth', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.post('/api/admin/groups/test-group-id/consents')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.set('X-CSRF-Token', adminSession.csrfToken)
|
||||
.send({})
|
||||
.expect(400);
|
||||
|
||||
|
|
@ -65,9 +66,8 @@ describe('Consent Management API', () => {
|
|||
|
||||
describe('GET /api/admin/groups/by-consent', () => {
|
||||
it('should return filtered groups', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/groups/by-consent')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
|
|
@ -77,9 +77,8 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should accept platform filter', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/groups/by-consent?platformId=1')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('groups');
|
||||
|
|
@ -87,9 +86,8 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should accept consent filter', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/groups/by-consent?displayInWorkshop=true')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('groups');
|
||||
|
|
@ -105,9 +103,8 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should return CSV format with auth and format parameter', async () => {
|
||||
const response = await getRequest()
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/consents/export?format=csv')
|
||||
.set('Authorization', `Bearer ${validToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toMatch(/text\/csv/);
|
||||
|
|
@ -115,9 +112,8 @@ describe('Consent Management API', () => {
|
|||
});
|
||||
|
||||
it('should include CSV header', async () => {
|
||||
const response = await getRequest()
|
||||
.get('/api/admin/consents/export?format=csv')
|
||||
.set('Authorization', `Bearer ${validToken}`);
|
||||
const response = await adminSession.agent
|
||||
.get('/api/admin/consents/export?format=csv');
|
||||
|
||||
expect(response.text).toContain('group_id');
|
||||
});
|
||||
|
|
|
|||
4
backend/tests/env.js
Normal file
4
backend/tests/env.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = process.env.PORT || '5001';
|
||||
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
|
||||
process.env.SKIP_PREVIEW_GENERATION = process.env.SKIP_PREVIEW_GENERATION || '1';
|
||||
|
|
@ -11,7 +11,7 @@ module.exports = async () => {
|
|||
// Set test environment variables
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = 5001;
|
||||
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
|
||||
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
|
||||
|
||||
try {
|
||||
// Create and initialize server
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
* Initialize server singleton here
|
||||
*/
|
||||
|
||||
// Ensure test environment variables are set before any application modules load
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
|
||||
process.env.PORT = process.env.PORT || 5001;
|
||||
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
|
||||
|
||||
const Server = require('../src/server');
|
||||
|
||||
// Singleton pattern - initialize only once
|
||||
|
|
@ -13,10 +18,6 @@ async function initializeTestServer() {
|
|||
if (!app) {
|
||||
console.log('🔧 Initializing test server (one-time)...');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.PORT = 5001;
|
||||
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
|
||||
|
||||
serverInstance = new Server(5001);
|
||||
app = await serverInstance.initializeApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,27 @@ const request = require('supertest');
|
|||
* Get supertest request instance
|
||||
* Uses globally initialized server from globalSetup.js
|
||||
*/
|
||||
function getRequest() {
|
||||
const app = global.__TEST_APP__;
|
||||
let cachedAgent = null;
|
||||
|
||||
function getApp() {
|
||||
const app = global.__TEST_APP__;
|
||||
if (!app) {
|
||||
throw new Error(
|
||||
'Test server not initialized. ' +
|
||||
'This should be handled by globalSetup.js automatically.'
|
||||
'Test server not initialized. This should be handled by globalSetup.js automatically.'
|
||||
);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
return request(app);
|
||||
function getRequest() {
|
||||
return request(getApp());
|
||||
}
|
||||
|
||||
function getAgent() {
|
||||
if (!cachedAgent) {
|
||||
cachedAgent = request.agent(getApp());
|
||||
}
|
||||
return cachedAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,5 +45,6 @@ async function teardownTestServer() {
|
|||
module.exports = {
|
||||
setupTestServer,
|
||||
teardownTestServer,
|
||||
getRequest
|
||||
getRequest,
|
||||
getAgent
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,81 +1,148 @@
|
|||
const { requireAdminAuth } = require('../../src/middlewares/auth');
|
||||
const AdminAuthService = require('../../src/services/AdminAuthService');
|
||||
const AdminUserRepository = require('../../src/repositories/AdminUserRepository');
|
||||
const dbManager = require('../../src/database/DatabaseManager');
|
||||
|
||||
describe('Auth Middleware Unit Test', () => {
|
||||
describe('Auth Middleware Unit Test (Session based)', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = { headers: {} };
|
||||
req = { session: null };
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
json: jest.fn(),
|
||||
locals: {}
|
||||
};
|
||||
next = jest.fn();
|
||||
process.env.ADMIN_API_KEY = 'test-key-123';
|
||||
});
|
||||
|
||||
test('should reject missing Authorization header', () => {
|
||||
test('should reject when no session exists', () => {
|
||||
requireAdminAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Zugriff verweigert',
|
||||
message: 'Authorization header fehlt'
|
||||
reason: 'SESSION_REQUIRED'
|
||||
})
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject invalid Bearer format', () => {
|
||||
req.headers.authorization = 'Invalid token';
|
||||
test('should reject when session user is missing', () => {
|
||||
req.session = {};
|
||||
|
||||
requireAdminAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Ungültiges Authorization Format')
|
||||
})
|
||||
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject wrong token', () => {
|
||||
req.headers.authorization = 'Bearer wrong-token';
|
||||
test('should reject non-admin roles', () => {
|
||||
req.session = { user: { id: 1, role: 'viewer' } };
|
||||
|
||||
requireAdminAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Ungültiger Admin-Token'
|
||||
})
|
||||
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow valid token', () => {
|
||||
req.headers.authorization = 'Bearer test-key-123';
|
||||
test('should pass through for admin sessions and expose user on locals', () => {
|
||||
const adminUser = { id: 1, role: 'admin', username: 'testadmin' };
|
||||
req.session = { user: adminUser };
|
||||
|
||||
requireAdminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle missing ADMIN_API_KEY', () => {
|
||||
delete process.env.ADMIN_API_KEY;
|
||||
req.headers.authorization = 'Bearer any-token';
|
||||
|
||||
requireAdminAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Server-Konfigurationsfehler'
|
||||
})
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.locals.adminUser).toEqual(adminUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminAuthService', () => {
|
||||
beforeEach(async () => {
|
||||
await dbManager.run('DELETE FROM admin_users');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await dbManager.run('DELETE FROM admin_users');
|
||||
});
|
||||
|
||||
test('needsInitialSetup reflects admin count', async () => {
|
||||
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(true);
|
||||
|
||||
await AdminAuthService.createInitialAdmin({
|
||||
username: 'existing',
|
||||
password: 'SuperSecure123!'
|
||||
});
|
||||
|
||||
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('createInitialAdmin validates input and detects completed setup', async () => {
|
||||
await expect(
|
||||
AdminAuthService.createInitialAdmin({ username: '', password: 'SuperSecure123!' })
|
||||
).rejects.toThrow('USERNAME_REQUIRED');
|
||||
|
||||
await expect(
|
||||
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'short' })
|
||||
).rejects.toThrow('PASSWORD_TOO_WEAK');
|
||||
|
||||
await AdminAuthService.createInitialAdmin({ username: 'seed', password: 'SuperSecure123!' });
|
||||
await expect(
|
||||
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'SuperSecure123!' })
|
||||
).rejects.toThrow('SETUP_ALREADY_COMPLETED');
|
||||
});
|
||||
|
||||
test('createInitialAdmin persists normalized admin when setup allowed', async () => {
|
||||
const result = await AdminAuthService.createInitialAdmin({
|
||||
username: 'TestAdmin',
|
||||
password: 'SuperSecure123!'
|
||||
});
|
||||
|
||||
expect(result.username).toBe('testadmin');
|
||||
expect(result.role).toBe('admin');
|
||||
|
||||
const stored = await AdminUserRepository.getByUsername('testadmin');
|
||||
expect(stored).toMatchObject({ username: 'testadmin', role: 'admin', is_active: 1 });
|
||||
});
|
||||
|
||||
test('verifyCredentials handles missing users and password mismatches', async () => {
|
||||
await expect(AdminAuthService.verifyCredentials('admin', 'pw')).resolves.toBeNull();
|
||||
|
||||
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
|
||||
await AdminUserRepository.createAdminUser({
|
||||
username: 'admin',
|
||||
passwordHash: hash,
|
||||
role: 'admin',
|
||||
requiresPasswordChange: false
|
||||
});
|
||||
|
||||
await expect(AdminAuthService.verifyCredentials('admin', 'wrong')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('verifyCredentials returns sanitized user for valid credentials', async () => {
|
||||
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
|
||||
await AdminUserRepository.createAdminUser({
|
||||
username: 'admin',
|
||||
passwordHash: hash,
|
||||
role: 'admin',
|
||||
requiresPasswordChange: true
|
||||
});
|
||||
|
||||
const result = await AdminAuthService.verifyCredentials('admin', 'SuperSecure123!');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
requiresPasswordChange: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
153
backend/tests/unit/groupCleanupService.test.js
Normal file
153
backend/tests/unit/groupCleanupService.test.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const fs = require('fs');
|
||||
const GroupRepository = require('../../src/repositories/GroupRepository');
|
||||
const DeletionLogRepository = require('../../src/repositories/DeletionLogRepository');
|
||||
const GroupCleanupService = require('../../src/services/GroupCleanupService');
|
||||
|
||||
describe('GroupCleanupService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getDaysUntilDeletion', () => {
|
||||
const NOW = new Date('2024-01-10T00:00:00Z');
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns remaining days when future deletion date is ahead', () => {
|
||||
const days = GroupCleanupService.getDaysUntilDeletion(new Date('2024-01-05T00:00:00Z'));
|
||||
expect(days).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps negative differences to zero', () => {
|
||||
const days = GroupCleanupService.getDaysUntilDeletion(new Date('2023-12-01T00:00:00Z'));
|
||||
expect(days).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePhysicalFiles', () => {
|
||||
it('counts successful deletions and ignores missing files', async () => {
|
||||
const unlinkMock = jest.spyOn(fs.promises, 'unlink');
|
||||
unlinkMock
|
||||
.mockResolvedValueOnce()
|
||||
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce();
|
||||
|
||||
const result = await GroupCleanupService.deletePhysicalFiles([
|
||||
{ file_path: 'images/one.jpg', preview_path: 'previews/one.jpg' },
|
||||
{ file_path: 'images/two.jpg', preview_path: 'previews/two.jpg' }
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ success: 2, failed: 1 });
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGroupsForDeletion', () => {
|
||||
it('fetches unapproved groups older than default threshold', async () => {
|
||||
const groups = [{ group_id: 'abc' }];
|
||||
const findSpy = jest
|
||||
.spyOn(GroupRepository, 'findUnapprovedGroupsOlderThan')
|
||||
.mockResolvedValue(groups);
|
||||
|
||||
const result = await GroupCleanupService.findGroupsForDeletion();
|
||||
|
||||
expect(findSpy).toHaveBeenCalledWith(GroupCleanupService.CLEANUP_DAYS);
|
||||
expect(result).toBe(groups);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGroupCompletely', () => {
|
||||
it('returns null when statistics are missing', async () => {
|
||||
jest.spyOn(GroupRepository, 'getGroupStatistics').mockResolvedValue(null);
|
||||
const deleteSpy = jest.spyOn(GroupRepository, 'deleteGroupCompletely').mockResolvedValue({});
|
||||
|
||||
const result = await GroupCleanupService.deleteGroupCompletely('missing-group');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes group, files and logs deletion', async () => {
|
||||
jest.spyOn(GroupRepository, 'getGroupStatistics').mockResolvedValue({
|
||||
groupId: 'group-1',
|
||||
year: 2024,
|
||||
imageCount: 3,
|
||||
uploadDate: '2024-01-01',
|
||||
totalFileSize: 1234
|
||||
});
|
||||
jest.spyOn(GroupRepository, 'deleteGroupCompletely').mockResolvedValue({
|
||||
imagePaths: [{ file_path: 'images/a.jpg', preview_path: 'previews/a.jpg' }],
|
||||
deletedImages: 3
|
||||
});
|
||||
|
||||
const deleteFilesSpy = jest
|
||||
.spyOn(GroupCleanupService, 'deletePhysicalFiles')
|
||||
.mockResolvedValue({ success: 2, failed: 0 });
|
||||
const logSpy = jest.spyOn(GroupCleanupService, 'logDeletion').mockResolvedValue();
|
||||
|
||||
const result = await GroupCleanupService.deleteGroupCompletely('group-1');
|
||||
|
||||
expect(deleteFilesSpy).toHaveBeenCalledWith([{ file_path: 'images/a.jpg', preview_path: 'previews/a.jpg' }]);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ groupId: 'group-1', imageCount: 3, totalFileSize: 1234 })
|
||||
);
|
||||
expect(result).toEqual({ groupId: 'group-1', imagesDeleted: 3, filesDeleted: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('logDeletion', () => {
|
||||
it('swallows repository errors so cleanup continues', async () => {
|
||||
jest.spyOn(DeletionLogRepository, 'createDeletionEntry').mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
GroupCleanupService.logDeletion({ groupId: 'g1', year: 2024, imageCount: 1, uploadDate: '2024-01-01' })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performScheduledCleanup', () => {
|
||||
it('returns early when there is nothing to delete', async () => {
|
||||
const findSpy = jest.spyOn(GroupCleanupService, 'findGroupsForDeletion').mockResolvedValue([]);
|
||||
|
||||
const result = await GroupCleanupService.performScheduledCleanup();
|
||||
|
||||
expect(findSpy).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
deletedGroups: 0,
|
||||
message: 'No groups to delete'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps track of successes and failures', async () => {
|
||||
const findSpy = jest
|
||||
.spyOn(GroupCleanupService, 'findGroupsForDeletion')
|
||||
.mockResolvedValue([{ group_id: 'g1' }, { group_id: 'g2' }]);
|
||||
const deleteSpy = jest
|
||||
.spyOn(GroupCleanupService, 'deleteGroupCompletely')
|
||||
.mockResolvedValueOnce()
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await GroupCleanupService.performScheduledCleanup();
|
||||
|
||||
expect(findSpy).toHaveBeenCalled();
|
||||
expect(deleteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.deletedGroups).toBe(1);
|
||||
expect(result.failedGroups).toBe(1);
|
||||
expect(result.duration).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
112
backend/tests/unit/groupFormatter.test.js
Normal file
112
backend/tests/unit/groupFormatter.test.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
const { formatGroupListRow, formatGroupDetail } = require('../../src/utils/groupFormatter');
|
||||
|
||||
describe('groupFormatter', () => {
|
||||
describe('formatGroupListRow', () => {
|
||||
it('maps snake_case columns to camelCase dto', () => {
|
||||
const row = {
|
||||
group_id: 'foo',
|
||||
year: 2024,
|
||||
title: 'Title',
|
||||
description: 'Desc',
|
||||
name: 'Alice',
|
||||
upload_date: '2024-01-01',
|
||||
approved: 1,
|
||||
image_count: '5',
|
||||
preview_image: 'path/to/thumb.jpg'
|
||||
};
|
||||
|
||||
expect(formatGroupListRow(row)).toEqual({
|
||||
groupId: 'foo',
|
||||
year: 2024,
|
||||
title: 'Title',
|
||||
description: 'Desc',
|
||||
name: 'Alice',
|
||||
uploadDate: '2024-01-01',
|
||||
approved: true,
|
||||
imageCount: 5,
|
||||
previewImage: 'path/to/thumb.jpg'
|
||||
});
|
||||
});
|
||||
|
||||
it('provides sane defaults when optional values missing', () => {
|
||||
const row = {
|
||||
group_id: 'bar',
|
||||
year: 2023,
|
||||
title: 'Other',
|
||||
description: null,
|
||||
name: null,
|
||||
upload_date: '2023-12-24',
|
||||
approved: 0,
|
||||
image_count: null,
|
||||
preview_image: undefined
|
||||
};
|
||||
|
||||
expect(formatGroupListRow(row)).toEqual({
|
||||
groupId: 'bar',
|
||||
year: 2023,
|
||||
title: 'Other',
|
||||
description: null,
|
||||
name: null,
|
||||
uploadDate: '2023-12-24',
|
||||
approved: false,
|
||||
imageCount: 0,
|
||||
previewImage: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGroupDetail', () => {
|
||||
it('maps nested image rows and flags', () => {
|
||||
const group = {
|
||||
group_id: 'foo',
|
||||
year: 2024,
|
||||
title: 'Title',
|
||||
description: 'Desc',
|
||||
name: 'Alice',
|
||||
upload_date: '2024-01-01',
|
||||
approved: 0,
|
||||
display_in_workshop: 1,
|
||||
consent_timestamp: null
|
||||
};
|
||||
const images = [
|
||||
{
|
||||
id: 1,
|
||||
file_name: 'one.png',
|
||||
original_name: 'one.png',
|
||||
file_path: 'images/one.png',
|
||||
preview_path: null,
|
||||
upload_order: 1,
|
||||
file_size: null,
|
||||
mime_type: 'image/png',
|
||||
image_description: 'desc'
|
||||
}
|
||||
];
|
||||
|
||||
expect(formatGroupDetail(group, images)).toEqual({
|
||||
groupId: 'foo',
|
||||
year: 2024,
|
||||
title: 'Title',
|
||||
description: 'Desc',
|
||||
name: 'Alice',
|
||||
uploadDate: '2024-01-01',
|
||||
approved: false,
|
||||
display_in_workshop: true,
|
||||
consent_timestamp: null,
|
||||
images: [
|
||||
{
|
||||
id: 1,
|
||||
fileName: 'one.png',
|
||||
originalName: 'one.png',
|
||||
filePath: 'images/one.png',
|
||||
previewPath: null,
|
||||
uploadOrder: 1,
|
||||
fileSize: null,
|
||||
mimeType: 'image/png',
|
||||
imageDescription: 'desc'
|
||||
}
|
||||
],
|
||||
imageCount: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
73
backend/tests/utils/adminSession.js
Normal file
73
backend/tests/utils/adminSession.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
const { getAgent } = require('../testServer');
|
||||
|
||||
const DEFAULT_CREDENTIALS = {
|
||||
username: 'testadmin',
|
||||
password: 'SuperSicher123!'
|
||||
};
|
||||
|
||||
let cachedSession = null;
|
||||
|
||||
async function initializeSession() {
|
||||
const agent = getAgent();
|
||||
|
||||
const statusResponse = await agent
|
||||
.get('/auth/setup/status')
|
||||
.expect(200);
|
||||
|
||||
let csrfToken;
|
||||
|
||||
if (statusResponse.body.needsSetup) {
|
||||
const setupResponse = await agent
|
||||
.post('/auth/setup/initial-admin')
|
||||
.send(DEFAULT_CREDENTIALS)
|
||||
.expect(201);
|
||||
|
||||
csrfToken = setupResponse.body?.csrfToken;
|
||||
} else {
|
||||
const loginResponse = await agent
|
||||
.post('/auth/login')
|
||||
.send(DEFAULT_CREDENTIALS);
|
||||
|
||||
if (loginResponse.status === 409 && loginResponse.body?.error === 'SETUP_REQUIRED') {
|
||||
// Edge case: setup status may lag behind – perform setup now
|
||||
const setupResponse = await agent
|
||||
.post('/auth/setup/initial-admin')
|
||||
.send(DEFAULT_CREDENTIALS)
|
||||
.expect(201);
|
||||
csrfToken = setupResponse.body?.csrfToken;
|
||||
} else if (loginResponse.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to log in test admin (status ${loginResponse.status}): ${JSON.stringify(loginResponse.body)}`
|
||||
);
|
||||
} else {
|
||||
csrfToken = loginResponse.body?.csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (!csrfToken) {
|
||||
const csrfResponse = await agent.get('/auth/csrf-token').expect(200);
|
||||
csrfToken = csrfResponse.body.csrfToken;
|
||||
}
|
||||
|
||||
cachedSession = { agent, csrfToken };
|
||||
return cachedSession;
|
||||
}
|
||||
|
||||
async function getAdminSession() {
|
||||
if (cachedSession) {
|
||||
return cachedSession;
|
||||
}
|
||||
return initializeSession();
|
||||
}
|
||||
|
||||
async function refreshCsrfToken() {
|
||||
const session = await getAdminSession();
|
||||
const csrfResponse = await session.agent.get('/auth/csrf-token').expect(200);
|
||||
session.csrfToken = csrfResponse.body.csrfToken;
|
||||
return session.csrfToken;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAdminSession,
|
||||
refreshCsrfToken
|
||||
};
|
||||
24
dev.sh
24
dev.sh
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
echo "🚀 Starting Project Image Uploader - Development Environment"
|
||||
echo "Starting Project Image Uploader - Development Environment"
|
||||
echo " Frontend: http://localhost:3000"
|
||||
echo " Backend: http://localhost:5001"
|
||||
echo ""
|
||||
|
|
@ -18,23 +18,23 @@ if docker compose ps | grep -q "image-uploader-frontend.*Up"; then
|
|||
fi
|
||||
|
||||
# Start development environment
|
||||
echo "📦 Starting development containers..."
|
||||
echo "Starting development containers..."
|
||||
docker compose -f docker/dev/docker-compose.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Development environment started!"
|
||||
echo "Development environment started!"
|
||||
echo ""
|
||||
echo "📊 Container Status:"
|
||||
echo "Container Status:"
|
||||
docker compose -f docker/dev/docker-compose.yml ps
|
||||
|
||||
echo ""
|
||||
echo "🔗 Access URLs:"
|
||||
echo " 📱 Frontend (Development): http://localhost:3000"
|
||||
echo " 🔧 Backend API (Development): http://localhost:5001"
|
||||
echo "Access URLs:"
|
||||
echo " Frontend (Development): http://localhost:3000"
|
||||
echo " Backend API (Development): http://localhost:5001"
|
||||
echo ""
|
||||
echo "📝 Useful Commands:"
|
||||
echo " 📋 Show logs: docker compose -f docker/dev/docker-compose.yml logs -f"
|
||||
echo " 🛑 Stop: docker compose -f docker/dev/docker-compose.yml down"
|
||||
echo " 🔄 Restart: docker compose -f docker/dev/docker-compose.yml restart"
|
||||
echo " 🏗️ Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache"
|
||||
echo "Useful Commands:"
|
||||
echo " Show logs: docker compose -f docker/dev/docker-compose.yml logs -f"
|
||||
echo " Stop: docker compose -f docker/dev/docker-compose.yml down"
|
||||
echo " Restart: docker compose -f docker/dev/docker-compose.yml restart"
|
||||
echo " Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache"
|
||||
echo ""
|
||||
|
|
@ -15,9 +15,10 @@ services:
|
|||
volumes:
|
||||
- ../../frontend:/app:cached
|
||||
- dev_frontend_node_modules:/app/node_modules
|
||||
- ./frontend/config/.env:/app/.env:ro
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- API_URL=http://backend-dev:5000
|
||||
- API_URL=http://localhost:5001
|
||||
- CLIENT_URL=http://localhost:3000
|
||||
depends_on:
|
||||
- backend-dev
|
||||
|
|
@ -36,6 +37,7 @@ services:
|
|||
volumes:
|
||||
- ../../backend:/usr/src/app:cached
|
||||
- dev_backend_node_modules:/usr/src/app/node_modules
|
||||
- ./backend/config/.env:/usr/src/app/.env:ro
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -23,9 +23,6 @@ RUN chmod +x ./env.sh
|
|||
# Copy nginx configuration for development
|
||||
COPY docker/dev/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy htpasswd file for authentication
|
||||
COPY docker/dev/frontend/config/htpasswd /etc/nginx/.htpasswd
|
||||
|
||||
# Make /app owned by the non-root user, then run npm as that user so
|
||||
# node_modules are created with the correct owner and we avoid an expensive
|
||||
# recursive chown later.
|
||||
|
|
|
|||
|
|
@ -28,11 +28,8 @@ server {
|
|||
# Frontend Routes (React Dev Server)
|
||||
# ========================================
|
||||
|
||||
# Protected route - Moderation (HTTP Basic Auth)
|
||||
# Moderation route proxy (session-protected in app layer)
|
||||
location /moderation {
|
||||
auth_basic "Restricted Area - Moderation";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ services:
|
|||
environment:
|
||||
- API_URL=http://backend:5000
|
||||
- CLIENT_URL=http://localhost
|
||||
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||
|
||||
networks:
|
||||
- npm-nw
|
||||
|
|
@ -36,7 +35,8 @@ services:
|
|||
- prod-internal
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
|
||||
|
||||
networks:
|
||||
npm-nw:
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ FROM nginx:stable-alpine
|
|||
RUN rm -rf /etc/nginx/conf.d
|
||||
COPY docker/prod/frontend/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy htpasswd file for authentication
|
||||
COPY docker/prod/frontend/config/htpasswd /etc/nginx/.htpasswd
|
||||
|
||||
# Static build
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
|
|
|
|||
|
|
@ -51,19 +51,6 @@ http {
|
|||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
# Protected API - Moderation API routes (password protected) - must come before /groups
|
||||
# Keep this route protected and proxy to backend if moderation endpoints exist there.
|
||||
location /moderation/groups {
|
||||
auth_basic "Restricted Area - Moderation API";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
proxy_pass http://image-uploader-backend:5000/moderation/groups;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API - Groups API routes (NO PASSWORD PROTECTION)
|
||||
location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ {
|
||||
proxy_pass http://image-uploader-backend:5000;
|
||||
|
|
@ -92,11 +79,8 @@ http {
|
|||
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
|
||||
}
|
||||
|
||||
# Protected routes - Moderation (password protected)
|
||||
# Moderation UI (session-protected within the app)
|
||||
location /moderation {
|
||||
auth_basic "Restricted Area - Moderation";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
# Frontend Environment Variables
|
||||
|
||||
# Admin API Authentication Token
|
||||
# Generate with: openssl rand -hex 32
|
||||
# Must match ADMIN_API_KEY in backend/.env
|
||||
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
|
||||
|
||||
# API Base URL (optional, defaults to same domain)
|
||||
# REACT_APP_API_URL=http://localhost:3001
|
||||
# Currently no frontend-specific secrets are required. Add overrides (e.g. public API URLs)
|
||||
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
|
||||
# Example:
|
||||
# REACT_APP_PUBLIC_API_BASE=https://example.com
|
||||
|
|
|
|||
|
|
@ -103,75 +103,73 @@ fetch('/api/admin/groups/123/approve') // Admin (+ Bearer Token!)
|
|||
fetch('/api/admin/groups/123') // Admin (+ Bearer Token!)
|
||||
```
|
||||
|
||||
### 2. Environment Variable für Admin Token hinzufügen
|
||||
### 2. Admin-Session & CSRF einrichten
|
||||
|
||||
```bash
|
||||
# frontend/.env oder frontend/.env.local
|
||||
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
|
||||
```
|
||||
Die Admin-API verwendet jetzt serverseitige Sessions mit CSRF-Schutz. Statt Tokens in `.env` zu hinterlegen, erfolgt die Authentifizierung über Login-Endpunkte:
|
||||
|
||||
**Token generieren:**
|
||||
```bash
|
||||
# Linux/Mac:
|
||||
openssl rand -hex 32
|
||||
1. **Setup-Status abfragen** – `GET /auth/setup/status` → `{ needsSetup, hasSession }`
|
||||
2. **Ersten Admin anlegen** – `POST /auth/setup/initial-admin` (nur einmal nötig)
|
||||
3. **Login** – `POST /auth/login` mit `{ username, password }`
|
||||
4. **CSRF Token holen** – `GET /auth/csrf-token` (liefert `csrfToken` und setzt HttpOnly Session-Cookie)
|
||||
|
||||
# Node.js:
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
**⚠️ Wichtig**: Gleiches Token in Backend `.env` als `ADMIN_API_KEY` eintragen!
|
||||
Alle nachfolgenden Admin-Requests senden automatisch das Session-Cookie (`credentials: 'include'`) und den `X-CSRF-Token` Header.
|
||||
|
||||
### 3. API-Aufrufe für Admin-Endpoints anpassen
|
||||
|
||||
#### Vorher (ohne Auth):
|
||||
#### Vorher (ohne Session):
|
||||
```javascript
|
||||
const response = await fetch('/api/admin/groups');
|
||||
```
|
||||
|
||||
#### Nachher (mit Bearer Token):
|
||||
#### Nachher (mit Session + CSRF):
|
||||
```javascript
|
||||
const response = await fetch('/api/admin/groups', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.REACT_APP_ADMIN_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
'X-CSRF-Token': csrfToken, // nur bei mutierenden Requests zwingend nötig
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Zentrale API-Helper-Funktion erstellen
|
||||
|
||||
**Empfohlen**: Erstelle eine zentrale Funktion für alle Admin-API-Calls:
|
||||
**Empfohlen**: Nutze `src/services/adminApi.js` als einzige Stelle, die Session- und CSRF-Handling kapselt:
|
||||
|
||||
```javascript
|
||||
// src/services/adminApiService.js
|
||||
const ADMIN_API_KEY = process.env.REACT_APP_ADMIN_API_KEY;
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let csrfToken = null;
|
||||
|
||||
const ensureCsrfToken = async () => {
|
||||
if (!csrfToken) {
|
||||
const response = await fetch('/auth/csrf-token', { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
csrfToken = data.csrfToken;
|
||||
}
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
export const adminFetch = async (url, options = {}) => {
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${ADMIN_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
headers.set('X-CSRF-Token', await ensureCsrfToken());
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error('Authentication failed - Invalid or missing admin token');
|
||||
if (!response.ok) {
|
||||
throw await parseError(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Verwendung:
|
||||
import { adminFetch } from './services/adminApiService';
|
||||
|
||||
const response = await adminFetch('/api/admin/groups');
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
### 4. Error Handling erweitern
|
||||
|
|
@ -179,22 +177,20 @@ const data = await response.json();
|
|||
```javascript
|
||||
try {
|
||||
const response = await adminFetch('/api/admin/groups');
|
||||
|
||||
if (response.status === 403) {
|
||||
// Auth fehlt oder ungültig
|
||||
console.error('Admin authentication required');
|
||||
// Redirect zu Login oder Fehlermeldung anzeigen
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
// Rate Limit überschritten
|
||||
console.error('Too many requests');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// ...
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
// Session abgelaufen
|
||||
redirectToLogin();
|
||||
} else if (error.status === 403 && error.reason === 'CSRF_INVALID') {
|
||||
// CSRF neu anfordern
|
||||
await adminSession.refreshStatus();
|
||||
} else if (error.status === 429) {
|
||||
notifyRateLimit();
|
||||
} else {
|
||||
console.error('Admin API error:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -225,7 +221,8 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
|||
- `Components/Pages/ModerationGroupsPage.js`
|
||||
- ❌ `/groups/${groupId}/approve` → ✅ `/api/admin/groups/${groupId}/approve`
|
||||
- ❌ `/groups/${groupId}` (DELETE) → ✅ `/api/admin/groups/${groupId}`
|
||||
- ❌ `/api/social-media/platforms` → ✅ `/api/admin/social-media/platforms`
|
||||
- ✅ `/api/admin/social-media/platforms` für Moderationsfilter
|
||||
- ✅ `/api/social-media/platforms` für öffentliche Formulare (keine Session nötig)
|
||||
|
||||
- `Components/Pages/ModerationGroupImagesPage.js`
|
||||
- ❌ `/moderation/groups/${groupId}` → ✅ `/api/admin/groups/${groupId}`
|
||||
|
|
@ -233,12 +230,12 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
|||
- `Components/Pages/PublicGroupImagesPage.js`
|
||||
- ❌ `/groups/${groupId}` → ✅ `/api/groups/${groupId}`
|
||||
|
||||
### Admin-Endpoints (benötigen Bearer Token):
|
||||
- `Components/Pages/ModerationGroupsPage.js` - Alle Admin-Calls
|
||||
### Admin-Endpoints (Session + CSRF erforderlich):
|
||||
- `Components/Pages/ModerationGroupsPage.js` - Alle Moderations-Calls
|
||||
- `Components/Pages/ModerationGroupImagesPage.js` - Gruppe laden + Bilder löschen
|
||||
- `Components/ComponentUtils/DeletionLogSection.js` - Deletion Log
|
||||
- `Components/ComponentUtils/ConsentManager.js` - Consent Export (wenn Admin)
|
||||
- `services/reorderService.js` - Admin-Reorder (wenn vorhanden)
|
||||
- `Components/ComponentUtils/ConsentManager.js` - Consent-Export (Admin)
|
||||
- `services/reorderService.js` - Admin-Reorder (falls im Einsatz)
|
||||
|
||||
### Public/Management Endpoints (nur Pfad prüfen):
|
||||
- `Utils/batchUpload.js` - Bereits korrekt (`/api/...`)
|
||||
|
|
@ -256,13 +253,12 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
|||
- [ ] Admin-Routen auf `/api/admin/*` geändert
|
||||
- [ ] Management-Routen auf `/api/manage/*` geprüft (sollten schon korrekt sein)
|
||||
|
||||
### Phase 2: Admin Authentication
|
||||
- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefügt
|
||||
- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert
|
||||
- [ ] Zentrale `adminFetch` Funktion erstellt
|
||||
- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefügt
|
||||
- [ ] Authorization Header zu `/api/system/*` Calls hinzugefügt (falls vorhanden)
|
||||
- [ ] 403 Error Handling implementiert
|
||||
### Phase 2: Admin Authentication (Session)
|
||||
- [ ] `AdminSessionProvider` wrappt die App
|
||||
- [ ] `AdminSessionGate` schützt alle Moderationsseiten
|
||||
- [ ] `adminApi.js` nutzt `credentials: 'include'` + `X-CSRF-Token`
|
||||
- [ ] Login- und Initial-Setup-Formulare eingebunden
|
||||
- [ ] Fehlerbehandlung für `401/403 (SESSION_REQUIRED/CSRF_INVALID)` ergänzt
|
||||
|
||||
### Phase 3: Testing & Deployment
|
||||
- [ ] Frontend lokal getestet (alle Routen)
|
||||
|
|
@ -276,29 +272,20 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
|||
|
||||
### Lokales Testing
|
||||
|
||||
1. Backend mit Admin-Key starten:
|
||||
```bash
|
||||
cd backend
|
||||
echo "ADMIN_API_KEY=test-key-12345" >> .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Frontend mit Admin-Key starten:
|
||||
```bash
|
||||
cd frontend
|
||||
echo "REACT_APP_ADMIN_API_KEY=test-key-12345" >> .env.local
|
||||
npm start
|
||||
```
|
||||
|
||||
3. Moderation-Seite öffnen und Admin-Funktionen testen
|
||||
1. Backend starten (`npm run dev`) – stellt Session- & Auth-Routen bereit.
|
||||
2. Frontend starten (`npm start`).
|
||||
3. `/moderation` öffnen:
|
||||
- **Falls kein Admin existiert** → Setup-Formular ausfüllen.
|
||||
- Danach mit frisch erstellten Credentials anmelden.
|
||||
4. Moderationsfunktionen (Approve/Delete/Reorder/Consent-Export) durchspielen.
|
||||
|
||||
### Test-Fälle
|
||||
|
||||
- ✅ Admin-Funktionen funktionieren mit gültigem Token
|
||||
- ✅ 403 Error bei fehlendem/falschem Token
|
||||
- ✅ Consent-Export funktioniert
|
||||
- ✅ Gruppen löschen funktioniert
|
||||
- ✅ Bilder neu anordnen funktioniert
|
||||
- ✅ Moderation funktioniert mit aktiver Session
|
||||
- ✅ Login/Logout ändert sofort den Zugriff auf Seiten
|
||||
- ✅ CSRF-geschützte Aktionen schlagen fehl, wenn Token manipuliert wird
|
||||
- ✅ Consent-Export & Reorder funktionieren weiterhin
|
||||
- ✅ Öffentliche Routen bleiben ohne Login erreichbar
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -308,56 +295,46 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
|||
- **API Route-Übersicht**: `backend/src/routes/README.md`
|
||||
- **Route-Konfiguration**: `backend/src/routes/routeMappings.js`
|
||||
- **OpenAPI Spec**: `backend/docs/openapi.json`
|
||||
- **Swagger UI**: http://localhost:5001/api/docs (dev only)
|
||||
- **Swagger UI**: http://localhost:5001/api/docs/ (dev only)
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Problem: "403 Forbidden" Fehler
|
||||
### Problem: "Session Required" / 403 Fehler
|
||||
|
||||
**Ursachen:**
|
||||
1. `REACT_APP_ADMIN_API_KEY` nicht gesetzt
|
||||
2. Token falsch konfiguriert (Frontend ≠ Backend)
|
||||
3. Token enthält Leerzeichen/Zeilenumbrüche
|
||||
1. Session abgelaufen (Inaktivität, Browser geschlossen)
|
||||
2. Cookies blockiert (Third-Party/SameSite Einstellungen)
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
# Frontend .env prüfen:
|
||||
cat frontend/.env | grep ADMIN_API_KEY
|
||||
- Seite neu laden → Login-Formular erscheint
|
||||
- Browser-Einstellungen prüfen: Cookies für Host erlauben
|
||||
|
||||
# Backend .env prüfen:
|
||||
cat backend/.env | grep ADMIN_API_KEY
|
||||
### Problem: "CSRF invalid"
|
||||
|
||||
# Beide müssen identisch sein!
|
||||
```
|
||||
|
||||
### Problem: "ADMIN_API_KEY not configured" (500 Error)
|
||||
|
||||
**Ursache:** Backend hat `ADMIN_API_KEY` nicht in `.env`
|
||||
**Ursachen:**
|
||||
- CSRF-Token nicht gesetzt oder veraltet
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
cd backend
|
||||
echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
- `AdminSessionGate` neu laden → holt automatisch neues Token
|
||||
- Sicherstellen, dass `adminApi` bei mutierenden Calls `X-CSRF-Token` setzt
|
||||
|
||||
### Problem: Token wird nicht gesendet
|
||||
### Problem: Setup-Formular erscheint nicht
|
||||
|
||||
**Prüfen in Browser DevTools:**
|
||||
1. Network Tab öffnen
|
||||
2. Admin-API-Request auswählen
|
||||
3. "Headers" Tab prüfen
|
||||
4. Sollte enthalten: `Authorization: Bearer <token>`
|
||||
**Ursachen:**
|
||||
- Bereits ein Admin vorhanden
|
||||
|
||||
### Problem: CORS-Fehler
|
||||
**Lösung:**
|
||||
- Bestehende Admin-Credentials verwenden
|
||||
- Falls vergessen: über Datenbank (Tabelle `admin_users`) neuen Admin eintragen oder Passwort zurücksetzen
|
||||
|
||||
**Ursache:** Backend CORS-Middleware blockiert Authorization-Header
|
||||
### Problem: Login schlägt wiederholt fehl
|
||||
|
||||
**Lösung:** Bereits implementiert in `backend/src/middlewares/cors.js`:
|
||||
```javascript
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
```
|
||||
**Checks:**
|
||||
1. Backend-Logs prüfen (Rate-Limits? falsches Passwort?)
|
||||
2. Prüfen, ob `ADMIN_SESSION_SECRET` gesetzt ist (sonst keine stabilen Sessions)
|
||||
3. Browser-Konsole → Network Request `POST /auth/login` analysieren
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -365,13 +342,11 @@ allowedHeaders: ['Content-Type', 'Authorization']
|
|||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Sicheren Admin-Token generiert (min. 32 Bytes hex)
|
||||
- [ ] Token in Backend `.env` als `ADMIN_API_KEY`
|
||||
- [ ] Token in Frontend Build-Environment als `REACT_APP_ADMIN_API_KEY`
|
||||
- [ ] Token NICHT in Git committed (in `.gitignore`)
|
||||
- [ ] HTTPS verwendet (Token über unverschlüsselte HTTP ist unsicher)
|
||||
- [ ] Token-Rotation-Prozess dokumentiert
|
||||
- [ ] Backup des Tokens an sicherem Ort gespeichert
|
||||
- [ ] Sicheres `ADMIN_SESSION_SECRET` (>= 32 random bytes) gesetzt
|
||||
- [ ] HTTPS aktiviert (Cookies: `Secure`, `SameSite=Strict`)
|
||||
- [ ] Session-DB Pfad (`ADMIN_SESSION_DIR`/`ADMIN_SESSION_DB`) persistent gemacht
|
||||
- [ ] Admin-Benutzer erstellt und dokumentiert (kein Secret im Frontend)
|
||||
- [ ] Monitoring/Alerting für fehlgeschlagene Logins eingerichtet
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
|
|
@ -380,16 +355,17 @@ allowedHeaders: ['Content-Type', 'Authorization']
|
|||
services:
|
||||
backend:
|
||||
environment:
|
||||
- ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||
|
||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||
- ADMIN_SESSION_DIR=/data/sessions
|
||||
# optional weitere Backend-ENV Variablen
|
||||
frontend:
|
||||
environment:
|
||||
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||
- PUBLIC_URL=${PUBLIC_URL:-/}
|
||||
```
|
||||
|
||||
```bash
|
||||
# .env (nicht in Git!)
|
||||
ADMIN_API_KEY=your-production-token-here
|
||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"proxy": "http://localhost:5001",
|
||||
"proxy": "http://backend-dev:5000",
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:0.85rem; transition:background-color 0.2s; flex:1; min-width:80px; }
|
||||
.btn-secondary { background:#6c757d; color:white; }
|
||||
.btn-secondary:hover { background:#5a6268; }
|
||||
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
|
||||
.btn-outline-secondary:hover:not(:disabled) { background:#6c757d; color:white; }
|
||||
.btn-success { background:#28a745; color:white; }
|
||||
.btn-success:hover { background:#218838; }
|
||||
.btn-warning { background:#ffc107; color:#212529; }
|
||||
|
|
@ -60,6 +62,7 @@
|
|||
.btn-danger { background:#dc3545; color:white; }
|
||||
.btn-danger:hover { background:#c82333; }
|
||||
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
|
||||
.btn:disabled { opacity:0.65; cursor:not-allowed; }
|
||||
|
||||
/* Modal */
|
||||
.image-modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); display:flex; align-items:center; justify-content:center; z-index:1000; padding:20px; }
|
||||
|
|
@ -93,3 +96,9 @@
|
|||
.home-button { border-radius:25px; padding:12px 30px; font-size:16px; font-weight:500; text-transform:none; border:2px solid #4CAF50; color:#4CAF50; background-color: transparent; cursor:pointer; }
|
||||
.empty-state { text-align:center; padding:60px 20px; }
|
||||
.loading-container { text-align:center; padding:60px 20px; }
|
||||
|
||||
/* Admin Auth */
|
||||
.admin-auth-wrapper { min-height: 70vh; display:flex; align-items:center; justify-content:center; padding:40px 16px; }
|
||||
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
|
||||
.admin-auth-form { width:100%; }
|
||||
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import './App.css';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
|
||||
|
||||
// Pages
|
||||
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
||||
|
|
@ -13,6 +14,7 @@ import FZF from './Components/Pages/404Page.js'
|
|||
|
||||
function App() {
|
||||
return (
|
||||
<AdminSessionProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" exact element={<MultiUploadPage />} />
|
||||
|
|
@ -25,6 +27,7 @@ function App() {
|
|||
<Route path="*" element={<FZF />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AdminSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
81
frontend/src/Components/AdminAuth/AdminLoginForm.jsx
Normal file
81
frontend/src/Components/AdminAuth/AdminLoginForm.jsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, TextField, Button, Alert, Stack } from '@mui/material';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
||||
const AdminLoginForm = () => {
|
||||
const { login, refreshStatus } = useAdminSession();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await login({ username, password });
|
||||
await refreshStatus();
|
||||
} catch (loginError) {
|
||||
if (loginError.code === 'INVALID_CREDENTIALS') {
|
||||
setError('Benutzername oder Passwort ist ungültig.');
|
||||
} else if (loginError.code === 'SETUP_REQUIRED') {
|
||||
setError('Initiales Setup erforderlich. Bitte Setup-Assistent ausführen.');
|
||||
} else {
|
||||
setError(loginError.message || 'Login fehlgeschlagen.');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-auth-card">
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
Admin Login
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Bitte melden Sie sich an, um das Moderations-Dashboard zu öffnen.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="admin-auth-form">
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Benutzername"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Anmeldung…' : 'Anmelden'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginForm;
|
||||
57
frontend/src/Components/AdminAuth/AdminSessionGate.jsx
Normal file
57
frontend/src/Components/AdminAuth/AdminSessionGate.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
import InitialAdminSetupForm from './InitialAdminSetupForm.jsx';
|
||||
import AdminLoginForm from './AdminLoginForm.jsx';
|
||||
import ForcePasswordChangeForm from './ForcePasswordChangeForm.jsx';
|
||||
|
||||
const AdminSessionGate = ({ children }) => {
|
||||
const { loading, needsSetup, isAuthenticated, error, user } = useAdminSession();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-auth-wrapper">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="admin-auth-wrapper">
|
||||
<div className="admin-auth-error">
|
||||
<p>Fehler beim Laden des Authentifizierungsstatus.</p>
|
||||
<p>{error.message || 'Bitte später erneut versuchen.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
return (
|
||||
<div className="admin-auth-wrapper">
|
||||
<InitialAdminSetupForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="admin-auth-wrapper">
|
||||
<AdminLoginForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.requiresPasswordChange) {
|
||||
return (
|
||||
<div className="admin-auth-wrapper">
|
||||
<ForcePasswordChangeForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AdminSessionGate;
|
||||
119
frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx
Normal file
119
frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, TextField, Button, Alert, Stack } from '@mui/material';
|
||||
import { changePassword } from '../../services/adminApi';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
||||
const ForcePasswordChangeForm = () => {
|
||||
const { user, refreshStatus } = useAdminSession();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!currentPassword) {
|
||||
setError('Bitte aktuelles Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 10) {
|
||||
setError('Das neue Passwort muss mindestens 10 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Die neuen Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await changePassword({ currentPassword, newPassword });
|
||||
setSuccess('Passwort wurde aktualisiert. Bitte erneut anmelden falls nötig.');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
await refreshStatus();
|
||||
} catch (changeError) {
|
||||
if (changeError.code === 'INVALID_CURRENT_PASSWORD') {
|
||||
setError('Das aktuelle Passwort ist falsch.');
|
||||
} else if (changeError.code === 'PASSWORD_TOO_WEAK') {
|
||||
setError('Das neue Passwort ist zu schwach (mindestens 10 Zeichen).');
|
||||
} else {
|
||||
setError(changeError.message || 'Passwort konnte nicht geändert werden.');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-auth-card">
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
Passwort aktualisieren
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Der Benutzer "{user?.username}" muss das initiale Passwort ändern, bevor das Dashboard genutzt werden kann.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="admin-auth-form">
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Aktuelles Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Neues Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Aktualisiere...' : 'Passwort speichern'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForcePasswordChangeForm;
|
||||
109
frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx
Normal file
109
frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, Typography, TextField, Button, Alert, Stack } from '@mui/material';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
||||
const InitialAdminSetupForm = () => {
|
||||
const { createInitialAdmin, refreshStatus } = useAdminSession();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const validate = () => {
|
||||
if (password !== passwordConfirm) {
|
||||
setError('Passwörter stimmen nicht überein.');
|
||||
return false;
|
||||
}
|
||||
if (password.length < 10) {
|
||||
setError('Passwort muss mindestens 10 Zeichen lang sein.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createInitialAdmin({ username, password });
|
||||
await refreshStatus();
|
||||
} catch (setupError) {
|
||||
if (setupError.code === 'SETUP_ALREADY_COMPLETED') {
|
||||
setError('Setup wurde bereits durchgeführt. Bitte anmelden.');
|
||||
} else if (setupError.code === 'USERNAME_IN_USE') {
|
||||
setError('Benutzername ist bereits vergeben.');
|
||||
} else if (setupError.code === 'PASSWORD_TOO_WEAK') {
|
||||
setError('Passwort entspricht nicht den Mindestanforderungen.');
|
||||
} else {
|
||||
setError(setupError.message || 'Initiales Setup fehlgeschlagen.');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-auth-card">
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
Initialer Admin-Zugang
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Es wurde noch kein Admin-Benutzer angelegt. Bitte erstellen Sie den ersten Account.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="admin-auth-form">
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Benutzername"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
helperText="Mindestens 10 Zeichen"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort bestätigen"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Speichern…' : 'Admin anlegen'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialAdminSetupForm;
|
||||
|
|
@ -10,8 +10,7 @@ import {
|
|||
} from '@mui/material';
|
||||
|
||||
// Services
|
||||
import { adminGet } from '../../../services/adminApi';
|
||||
import { handleAdminError } from '../../../services/adminErrorHandler';
|
||||
import { getActiveSocialMediaPlatforms } from '../../../services/socialMediaApi';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import FacebookIcon from '@mui/icons-material/Facebook';
|
||||
import InstagramIcon from '@mui/icons-material/Instagram';
|
||||
|
|
@ -56,11 +55,11 @@ function ConsentCheckboxes({
|
|||
|
||||
const fetchPlatforms = async () => {
|
||||
try {
|
||||
const data = await adminGet('/api/admin/social-media/platforms');
|
||||
const data = await getActiveSocialMediaPlatforms();
|
||||
setPlatforms(data);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
await handleAdminError(error, 'Plattformen laden');
|
||||
console.error('Fehler beim Laden der Plattformen:', error);
|
||||
setError('Plattformen konnten nicht geladen werden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Container, Box } from '@mui/material';
|
|||
// Services
|
||||
import { adminGet } from '../../services/adminApi';
|
||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
||||
// Components
|
||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||
|
|
@ -26,8 +28,12 @@ const ModerationGroupImagesPage = () => {
|
|||
const [group, setGroup] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const { isAuthenticated } = useAdminSession();
|
||||
|
||||
const loadGroup = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminGet(`/api/admin/groups/${groupId}`);
|
||||
|
|
@ -57,13 +63,16 @@ const ModerationGroupImagesPage = () => {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
}, [groupId, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
loadGroup();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupId]);
|
||||
}, [isAuthenticated, loadGroup]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <Loading />;
|
||||
if (error) return <div className="moderation-error">{error}</div>;
|
||||
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>;
|
||||
|
|
@ -103,6 +112,13 @@ const ModerationGroupImagesPage = () => {
|
|||
<div className="footerContainer"><Footer /></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSessionGate>
|
||||
{renderContent()}
|
||||
</AdminSessionGate>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationGroupImagesPage;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Typography } from '@mui/material';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
||||
|
||||
// Services
|
||||
import { adminGet, adminRequest, adminDownload } from '../../services/adminApi';
|
||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||
import { getActiveSocialMediaPlatforms } from '../../services/socialMediaApi';
|
||||
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
|
||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||
|
||||
// Components
|
||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||
|
|
@ -30,19 +33,27 @@ const ModerationGroupsPage = () => {
|
|||
});
|
||||
const [platforms, setPlatforms] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, logout, user } = useAdminSession();
|
||||
const [logoutPending, setLogoutPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
loadModerationGroups();
|
||||
loadPlatforms();
|
||||
}, []);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
loadModerationGroups();
|
||||
}, [consentFilters]);
|
||||
}, [consentFilters, isAuthenticated]);
|
||||
|
||||
const loadPlatforms = async () => {
|
||||
try {
|
||||
const data = await adminGet('/api/admin/social-media/platforms');
|
||||
const data = await getActiveSocialMediaPlatforms();
|
||||
setPlatforms(data);
|
||||
} catch (error) {
|
||||
await handleAdminError(error, 'Plattformen laden');
|
||||
|
|
@ -146,7 +157,18 @@ const ModerationGroupsPage = () => {
|
|||
};
|
||||
|
||||
const deleteGroup = async (groupId) => {
|
||||
if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Gruppe löschen?',
|
||||
text: 'Die Löschung kann nicht rückgängig gemacht werden.',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Ja, löschen',
|
||||
cancelButtonText: 'Abbrechen'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +180,14 @@ const ModerationGroupsPage = () => {
|
|||
setSelectedGroup(null);
|
||||
setShowImages(false);
|
||||
}
|
||||
|
||||
await Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Gruppe gelöscht',
|
||||
text: 'Die Gruppe wurde vollständig entfernt.',
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
});
|
||||
} catch (error) {
|
||||
await handleAdminError(error, 'Gruppe löschen');
|
||||
}
|
||||
|
|
@ -193,6 +223,30 @@ const ModerationGroupsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLogoutClick = async () => {
|
||||
try {
|
||||
setLogoutPending(true);
|
||||
await logout();
|
||||
await Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Abgemeldet',
|
||||
text: 'Die Admin-Session wurde beendet.',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
} catch (logoutError) {
|
||||
console.error('Logout fehlgeschlagen:', logoutError);
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Logout fehlgeschlagen',
|
||||
text: logoutError?.message || 'Bitte später erneut versuchen.'
|
||||
});
|
||||
} finally {
|
||||
setLogoutPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <div className="moderation-loading">Lade Gruppen...</div>;
|
||||
}
|
||||
|
|
@ -215,7 +269,34 @@ const ModerationGroupsPage = () => {
|
|||
</Helmet>
|
||||
|
||||
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
|
||||
<h1>Moderation</h1>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Moderation
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{user?.username && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Eingeloggt als <strong>{user.username}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={handleLogoutClick}
|
||||
disabled={logoutPending}
|
||||
style={{ minWidth: '120px' }}
|
||||
>
|
||||
{logoutPending ? 'Wird abgemeldet…' : 'Logout'}
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="moderation-stats">
|
||||
<div className="stat-item">
|
||||
|
|
@ -349,6 +430,13 @@ const ModerationGroupsPage = () => {
|
|||
<div className="footerContainer"><Footer /></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSessionGate>
|
||||
{renderContent()}
|
||||
</AdminSessionGate>
|
||||
);
|
||||
};
|
||||
|
||||
// `GroupCard` has been extracted to `../ComponentUtils/GroupCard`
|
||||
|
|
|
|||
98
frontend/src/contexts/AdminSessionContext.jsx
Normal file
98
frontend/src/contexts/AdminSessionContext.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
getSetupStatus,
|
||||
createInitialAdmin,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
fetchCsrfToken,
|
||||
clearStoredCsrfToken
|
||||
} from '../services/adminApi';
|
||||
|
||||
const AdminSessionContext = createContext(null);
|
||||
|
||||
export const AdminSessionProvider = ({ children }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await getSetupStatus();
|
||||
setNeedsSetup(Boolean(status.needsSetup));
|
||||
setIsAuthenticated(Boolean(status.hasSession));
|
||||
setUser(status.user || null);
|
||||
if (status.hasSession) {
|
||||
try {
|
||||
await fetchCsrfToken();
|
||||
} catch (csrfError) {
|
||||
console.warn('[AdminSession] CSRF refresh failed', csrfError);
|
||||
}
|
||||
}
|
||||
} catch (statusError) {
|
||||
console.error('[AdminSession] setup status failed', statusError);
|
||||
setError(statusError);
|
||||
setNeedsSetup(false);
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const handleLogin = useCallback(async (credentials) => {
|
||||
const result = await loginRequest(credentials);
|
||||
setIsAuthenticated(true);
|
||||
setNeedsSetup(false);
|
||||
setUser(result.user || null);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const handleInitialAdmin = useCallback(async (payload) => {
|
||||
const result = await createInitialAdmin(payload);
|
||||
setIsAuthenticated(true);
|
||||
setNeedsSetup(false);
|
||||
setUser(result.user || null);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
clearStoredCsrfToken();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
loading,
|
||||
needsSetup,
|
||||
isAuthenticated,
|
||||
user,
|
||||
error,
|
||||
refreshStatus,
|
||||
login: handleLogin,
|
||||
logout: handleLogout,
|
||||
createInitialAdmin: handleInitialAdmin
|
||||
}), [loading, needsSetup, isAuthenticated, user, error, refreshStatus, handleLogin, handleLogout, handleInitialAdmin]);
|
||||
|
||||
return (
|
||||
<AdminSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</AdminSessionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminSession = () => {
|
||||
const context = useContext(AdminSessionContext);
|
||||
if (!context) {
|
||||
throw new Error('useAdminSession must be used within AdminSessionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,62 +1,108 @@
|
|||
/**
|
||||
* Admin API Helper mit Bearer Token Authentication
|
||||
*
|
||||
* Verwendet für alle /api/admin/* und /api/system/* Endpoints
|
||||
*/
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
const CSRF_STORAGE_KEY = 'piu.admin.csrfToken';
|
||||
|
||||
/**
|
||||
* Führt einen fetch-Request mit Admin-Bearer-Token aus
|
||||
* @param {string} url - Die URL (mit /api/admin/* oder /api/system/* Prefix)
|
||||
* @param {object} options - Fetch options (method, body, headers, etc.)
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export const adminFetch = async (url, options = {}) => {
|
||||
const token = process.env.REACT_APP_ADMIN_API_KEY;
|
||||
let csrfToken = null;
|
||||
if (typeof window !== 'undefined' && window.sessionStorage) {
|
||||
csrfToken = window.sessionStorage.getItem(CSRF_STORAGE_KEY);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.error('REACT_APP_ADMIN_API_KEY not configured!');
|
||||
throw new Error('Admin API Token not configured');
|
||||
const persistCsrfToken = (token) => {
|
||||
csrfToken = token;
|
||||
if (typeof window !== 'undefined' && window.sessionStorage) {
|
||||
if (token) {
|
||||
window.sessionStorage.setItem(CSRF_STORAGE_KEY, token);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(CSRF_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hilfsfunktion für GET-Requests mit automatischer JSON-Parsing und Error-Handling
|
||||
* @param {string} url
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const adminGet = async (url) => {
|
||||
const response = await adminFetch(url);
|
||||
const parseErrorResponse = async (response) => {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
const error = new Error(payload?.error || payload?.message || response.statusText);
|
||||
error.status = response.status;
|
||||
error.reason = payload?.reason;
|
||||
error.code = payload?.error || payload?.code;
|
||||
error.payload = payload;
|
||||
return error;
|
||||
};
|
||||
|
||||
export const clearStoredCsrfToken = () => {
|
||||
persistCsrfToken(null);
|
||||
};
|
||||
|
||||
export const fetchCsrfToken = async () => {
|
||||
const response = await fetch('/auth/csrf-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
throw new Error('Unauthorized: Invalid or missing admin token');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error('Too many requests: Rate limit exceeded');
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.csrfToken) {
|
||||
persistCsrfToken(data.csrfToken);
|
||||
}
|
||||
return data?.csrfToken;
|
||||
};
|
||||
|
||||
const ensureCsrfToken = async () => {
|
||||
if (!csrfToken) {
|
||||
await fetchCsrfToken();
|
||||
}
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
export const adminFetch = async (url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
const needsCsrf = options.requireCsrf !== false && !SAFE_METHODS.has(method);
|
||||
|
||||
if (needsCsrf) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
} else if (csrfToken) {
|
||||
headers.set('X-CSRF-Token', csrfToken);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Attempt to refresh CSRF token if it might be invalid
|
||||
if (response.status === 403 && needsCsrf) {
|
||||
try {
|
||||
await fetchCsrfToken();
|
||||
} catch (error) {
|
||||
// ignore, original error will be thrown below
|
||||
}
|
||||
}
|
||||
|
||||
throw await parseErrorResponse(response);
|
||||
};
|
||||
|
||||
export const adminGet = async (url) => {
|
||||
const response = await adminFetch(url, { method: 'GET', requireCsrf: false });
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Hilfsfunktion für POST/PUT/PATCH/DELETE mit JSON body
|
||||
* @param {string} url
|
||||
* @param {string} method
|
||||
* @param {object} body
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export const adminRequest = async (url, method, body = null) => {
|
||||
const options = {
|
||||
method,
|
||||
|
|
@ -70,37 +116,81 @@ export const adminRequest = async (url, method, body = null) => {
|
|||
}
|
||||
|
||||
const response = await adminFetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
throw new Error('Unauthorized: Invalid or missing admin token');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error('Too many requests: Rate limit exceeded');
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hilfsfunktion für Blob/File Downloads (CSV, PDF, etc.)
|
||||
* @param {string} url
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
export const adminDownload = async (url) => {
|
||||
const response = await adminFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
throw new Error('Unauthorized: Invalid or missing admin token');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error('Too many requests: Rate limit exceeded');
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const response = await adminFetch(url, { method: 'GET', requireCsrf: false });
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
export const getSetupStatus = async () => {
|
||||
const response = await fetch('/auth/setup/status', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createInitialAdmin = async ({ username, password }) => {
|
||||
const response = await fetch('/auth/setup/initial-admin', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.csrfToken) {
|
||||
persistCsrfToken(data.csrfToken);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const login = async ({ username, password }) => {
|
||||
const response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.csrfToken) {
|
||||
persistCsrfToken(data.csrfToken);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
clearStoredCsrfToken();
|
||||
};
|
||||
|
||||
export const changePassword = async ({ currentPassword, newPassword }) => {
|
||||
const response = await adminFetch('/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,30 +13,44 @@ import Swal from 'sweetalert2/dist/sweetalert2.js';
|
|||
export const handleAdminError = async (error, context = 'Operation') => {
|
||||
console.error(`Admin API Error [${context}]:`, error);
|
||||
|
||||
// 403 Unauthorized - Admin Token fehlt oder ungültig
|
||||
if (error.message.includes('Unauthorized') || error.message.includes('403')) {
|
||||
const status = error?.status;
|
||||
const reason = error?.reason || error?.code || error?.payload?.reason;
|
||||
|
||||
// Session missing or expired
|
||||
if (status === 401 || reason === 'SESSION_REQUIRED') {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Authentifizierung fehlgeschlagen',
|
||||
html: `
|
||||
<p><strong>Admin-Token fehlt oder ist ungültig.</strong></p>
|
||||
<p>Bitte kontaktieren Sie den Administrator.</p>
|
||||
<hr>
|
||||
<small>
|
||||
<strong>Technische Details:</strong><br>
|
||||
- Prüfen Sie die REACT_APP_ADMIN_API_KEY Variable<br>
|
||||
- Token muss mit Backend ADMIN_API_KEY übereinstimmen<br>
|
||||
- Kontext: ${context}
|
||||
</small>
|
||||
`,
|
||||
confirmButtonText: 'OK',
|
||||
confirmButtonColor: '#d33'
|
||||
icon: 'warning',
|
||||
title: 'Anmeldung erforderlich',
|
||||
text: 'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
|
||||
confirmButtonText: 'Zum Login'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 429 Rate Limit
|
||||
if (error.message.includes('Too many requests') || error.message.includes('429')) {
|
||||
// CSRF token invalid or missing
|
||||
if (status === 403 && (reason === 'CSRF_INVALID' || reason === 'CSRF_REQUIRED')) {
|
||||
await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Sicherheitsüberprüfung fehlgeschlagen',
|
||||
text: 'Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
|
||||
confirmButtonText: 'Neu laden'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic forbidden
|
||||
if (status === 403) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Keine Berechtigung',
|
||||
text: 'Sie besitzen keine Berechtigung für diese Aktion.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (status === 429) {
|
||||
await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Zu viele Anfragen',
|
||||
|
|
@ -47,33 +61,33 @@ export const handleAdminError = async (error, context = 'Operation') => {
|
|||
return;
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
if (error.message.includes('404')) {
|
||||
// Not found
|
||||
if (status === 404) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Nicht gefunden',
|
||||
text: `Die angeforderte Ressource wurde nicht gefunden.`,
|
||||
text: 'Die angeforderte Ressource wurde nicht gefunden.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 500 Server Error
|
||||
if (error.message.includes('500')) {
|
||||
// Server error
|
||||
if (status && status >= 500) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Server-Fehler',
|
||||
text: 'Ein interner Server-Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
|
||||
text: 'Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generischer Fehler
|
||||
// Generic fallback
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: `Fehler: ${context}`,
|
||||
text: error.message || 'Ein unbekannter Fehler ist aufgetreten.',
|
||||
text: error?.message || 'Ein unbekannter Fehler ist aufgetreten.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
};
|
||||
|
|
|
|||
21
frontend/src/services/socialMediaApi.js
Normal file
21
frontend/src/services/socialMediaApi.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const parseResponseBody = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getActiveSocialMediaPlatforms = async () => {
|
||||
const response = await fetch('/api/social-media/platforms');
|
||||
const payload = await parseResponseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload?.error || payload?.message || 'Plattformen konnten nicht geladen werden');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload || [];
|
||||
};
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
# Batch Image Uploader
|
||||
# Scripts Overview
|
||||
|
||||
## Admin-Benutzer anlegen (Shell)
|
||||
|
||||
```bash
|
||||
./create_admin_user.sh --username admin2 --password 'SehrSicher123!'
|
||||
```
|
||||
|
||||
Optionen entsprechen dem Node-Skript (`backend/src/scripts/createAdminUser.js`): `--role <rolle>` und `--require-password-change` sind verfügbar. Das Skript setzt automatisch `SKIP_PREVIEW_GENERATION=1`, prüft ob Node installiert ist und leitet alle Argumente an das bestehende CLI weiter. Alternativ kann weiterhin `npm run create-admin -- --username ...` im Backend-Ordner benutzt werden.
|
||||
|
||||
## Batch Image Uploader
|
||||
|
||||
Ein Python-Skript für automatischen Batch-Upload von Bildern mit Metadaten-Extraktion.
|
||||
|
||||
|
|
@ -6,9 +16,10 @@ Ein Python-Skript für automatischen Batch-Upload von Bildern mit Metadaten-Extr
|
|||
|
||||
- 🔍 **Rekursives Verzeichnis-Scanning** nach unterstützten Bildformaten
|
||||
- 📊 **Automatische Metadaten-Extraktion** aus EXIF-Daten und Pfad-Struktur
|
||||
- 🚀 **Batch-Upload** mit konfigurierbarer Chunk-Größe
|
||||
- 🚀 **Batch-Upload** mit automatischer Projekt-Gruppierung
|
||||
- 📈 **Progress-Tracking** und Error-Handling
|
||||
- 🏗️ **Strukturierte Metadaten** (Jahr, Titel, Beschreibung, Name)
|
||||
- 🔐 **Admin-Session Login** mit CSRF-Schutz (entsprechend `AUTHENTICATION.md`)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -25,10 +36,12 @@ pip install requests pillow
|
|||
### Einfacher Upload
|
||||
```bash
|
||||
# Linux/macOS
|
||||
python batch_uploader.py /path/to/images --titel "Meine Foto-Sammlung"
|
||||
python batch_uploader.py /path/to/images --titel "Meine Foto-Sammlung" \
|
||||
--user admin --password 'SehrSicher123!'
|
||||
|
||||
# Windows
|
||||
python batch_uploader.py "C:\Users\username\Photos" --titel "Meine Foto-Sammlung"
|
||||
python batch_uploader.py "C:\Users\username\Photos" --titel "Meine Foto-Sammlung" ^
|
||||
--user admin --password "SehrSicher123!"
|
||||
```
|
||||
|
||||
### Erweiterte Optionen
|
||||
|
|
@ -38,30 +51,38 @@ python batch_uploader.py ./photos \
|
|||
--titel "Urlaubsbilder 2024" \
|
||||
--name "Max Mustermann" \
|
||||
--backend http://localhost:5000 \
|
||||
--chunk-size 10
|
||||
--user admin --password 'SehrSicher123!' \
|
||||
--social-media-consents consents.json
|
||||
|
||||
## Windows / WSL: Pfade mit Leerzeichen und Sonderzeichen
|
||||
|
||||
Windows-PowerShell (empfohlen):
|
||||
```powershell
|
||||
# Doppelte Anführungszeichen um den kompletten Pfad, Backslashes bleiben unverändert
|
||||
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" --titel "Nextcloud Archive" --name "Lotz M." --verbose
|
||||
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" \
|
||||
--titel "Nextcloud Archive" --name "Lotz M." --verbose \
|
||||
--user admin --password "SehrSicher123!"
|
||||
```
|
||||
|
||||
Windows (CMD) – ohne Anführungszeichen mit Escapes oder mit Anführungszeichen:
|
||||
```bat
|
||||
REM Mit Backslashes escapen (CMD):
|
||||
python batch_uploader.py C:\Users\lotzm\Nextcloud2\HH\ DropFolder\ with\ quota\=NutzerBildUploads= --titel "Nextcloud Archive"
|
||||
python batch_uploader.py C:\Users\lotzm\Nextcloud2\HH\ DropFolder\ with\ quota\=NutzerBildUploads= \
|
||||
--titel "Nextcloud Archive" --user admin --password SehrSicher123!
|
||||
|
||||
REM Oder einfacher mit Anführungszeichen:
|
||||
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" --titel "Nextcloud Archive"
|
||||
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" \
|
||||
--titel "Nextcloud Archive" --user admin --password "SehrSicher123!"
|
||||
```
|
||||
|
||||
WSL / Linux (bash) – Pfad in /mnt/c/... verwenden, ohne zusätzliche Backslashes in Quotes:
|
||||
```bash
|
||||
python3 batch_uploader.py "/mnt/c/Users/lotzm/Nextcloud2/HH DropFolder with quota/=NutzerBildUploads=" --titel "Nextcloud Archive" --name "Lotz M." --verbose
|
||||
python3 batch_uploader.py "/mnt/c/Users/lotzm/Nextcloud2/HH DropFolder with quota/=NutzerBildUploads=" \
|
||||
--titel "Nextcloud Archive" --name "Lotz M." --verbose \
|
||||
--user admin --password 'SehrSicher123!'
|
||||
# oder ohne Quotes, mit Backslash-Escapes:
|
||||
python3 batch_uploader.py /mnt/c/Users/lotzm/Nextcloud2/HH\ DropFolder\ with\ quota/=NutzerBildUploads= --titel "Nextcloud Archive"
|
||||
python3 batch_uploader.py /mnt/c/Users/lotzm/Nextcloud2/HH\ DropFolder\ with\ quota/=NutzerBildUploads= \
|
||||
--titel "Nextcloud Archive" --user admin --password 'SehrSicher123!'
|
||||
```
|
||||
|
||||
Hinweis:
|
||||
|
|
@ -84,11 +105,44 @@ python batch_uploader.py ./images --no-recursive
|
|||
| `--titel` | Standard-Titel für alle Bilder | Aus Pfad extrahiert |
|
||||
| `--name` | Standard-Name für alle Bilder | Leer |
|
||||
| `--backend` | Backend-URL | `http://localhost:5000` |
|
||||
| `--chunk-size` | ~~Bilder pro Upload-Batch~~ (Deprecated) | ~~5~~ |
|
||||
| `--user / --password` | Admin-Credentials für Session-Login | - |
|
||||
| `--workshop-consent` / `--no-workshop-consent` | Zustimmung zur Anzeige in der Werkstatt | `True` |
|
||||
| `--social-media-consents` | JSON-String oder Datei mit Social-Media-Consents | `[]` |
|
||||
| `--no-recursive` | Nicht in Unterverzeichnisse | `False` |
|
||||
| `--dry-run` | Nur Analyse, kein Upload | `False` |
|
||||
| `--verbose` | Detailliertes Logging | `False` |
|
||||
|
||||
## Admin-Login & Consents
|
||||
|
||||
Der Batch-Uploader verwendet denselben Session-/CSRF-Flow wie die Admin-UI (siehe `AUTHENTICATION.md`).
|
||||
|
||||
1. **Admin-Benutzer vorbereiten** – entweder über die UI oder per `./create_admin_user.sh`.
|
||||
2. **Login-Daten übergeben** – `--user admin --password '•••'`.
|
||||
3. **Skript führt automatisch aus**:
|
||||
- `POST /auth/login`
|
||||
- `GET /auth/csrf-token` (falls nötig)
|
||||
- `POST /api/upload/batch` mit `X-CSRF-Token`
|
||||
|
||||
### Consents setzen
|
||||
|
||||
- `workshopConsent` ist Pflicht. Standard ist `True`, kann via `--no-workshop-consent` deaktiviert werden.
|
||||
- Social-Media-Consents können aus einer Datei oder einem JSON-String geladen werden:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "platformId": 1, "consented": true },
|
||||
{ "platformId": 2, "consented": false }
|
||||
]
|
||||
```
|
||||
|
||||
Aufruf-Beispiel:
|
||||
|
||||
```bash
|
||||
python batch_uploader.py ./photos \
|
||||
--user admin --password 'SehrSicher123!' \
|
||||
--social-media-consents consents.json
|
||||
```
|
||||
|
||||
## Metadaten-Extraktion
|
||||
|
||||
### Erwartete Struktur: `Photos/Jahr/Name/Projekt/dateiname.endung`
|
||||
|
|
@ -217,7 +271,7 @@ Traumhafter Urlaub auf Mallorca mit Sonne, Strand und entspannten Momenten am Po
|
|||
|
||||
- **Connection-Timeout**: 10s für Backend-Test, 60s für Upload
|
||||
- **File-Errors**: Automatisches Skip von beschädigten Bildern
|
||||
- **Chunk-Failures**: Einzelne Chunks können fehlschlagen ohne Abbruch
|
||||
- **Projekt-Fehler**: Fehler in einem Projekt stoppen den Gesamtprozess nicht
|
||||
- **Retry-Logic**: Verwendet Session für Connection-Reuse
|
||||
|
||||
## Cross-Platform Support
|
||||
|
|
@ -251,10 +305,8 @@ python batch_uploader.py "\\\\nas-server\\photos\\2024" --verbose
|
|||
|
||||
### Backend nicht erreichbar
|
||||
```bash
|
||||
### Backend Status prüfen
|
||||
```bash
|
||||
# Prüfe Backend-Status
|
||||
curl http://localhost:5000/groups
|
||||
curl http://localhost:5000/api/groups
|
||||
|
||||
# Backend starten
|
||||
cd ../
|
||||
|
|
@ -269,9 +321,10 @@ pip install --upgrade Pillow
|
|||
|
||||
### Performance bei großen Batches
|
||||
```bash
|
||||
# Kleinere Chunk-Size verwenden
|
||||
python batch_uploader.py /photos --chunk-size 3
|
||||
# Nach Jahr oder Name aufteilen
|
||||
python batch_uploader.py /photos/2024/Familie_Schmidt \
|
||||
--user admin --password 'SehrSicher123!'
|
||||
|
||||
# Progress verfolgen
|
||||
python batch_uploader.py /photos --verbose
|
||||
# Vorab prüfen
|
||||
python batch_uploader.py /photos --dry-run --verbose
|
||||
```
|
||||
|
|
@ -9,15 +9,8 @@ mit strukturierten Metadaten an das Image-Uploader Backend.
|
|||
Features:
|
||||
- Rekursives Verzeichnis-Scanning nach Bildern
|
||||
- Metadaten-Extraktion aus Verzeichnis-/Dateinamen
|
||||
- Batch-Upload an das Backen self.logger.info(f"📊 Upload abgeschlossen: {len(project_groups)} Gruppen erstellt")
|
||||
|
||||
return {
|
||||
'total': total_images,
|
||||
'successful': total_successful,
|
||||
'failed': total_failed,
|
||||
'failed_files': failed_files,
|
||||
'project_groups': project_groups # Für Übersicht am Ende
|
||||
}Progress-Tracking und Error-Handling
|
||||
- Batch-Upload an das Backend mit Session-Authentifizierung
|
||||
- Fortschritts-Tracking und Error-Handling
|
||||
- EXIF-Daten Unterstützung (optional)
|
||||
|
||||
Usage:
|
||||
|
|
@ -30,7 +23,7 @@ import json
|
|||
import requests
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from typing import Any, List, Dict, Optional, Tuple
|
||||
import mimetypes
|
||||
from PIL import Image, ExifTags
|
||||
from PIL.ExifTags import TAGS
|
||||
|
|
@ -39,11 +32,35 @@ from datetime import datetime
|
|||
import logging
|
||||
|
||||
# Konfiguration
|
||||
#DEFAULT_BACKEND_URL = "https://deinprojekt.lan.hobbyhimmel.de/api"
|
||||
DEFAULT_BACKEND_URL = "http://localhost/api"
|
||||
DEFAULT_BACKEND_URL = "http://localhost:5000"
|
||||
SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
def load_social_media_consents(input_value: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Lädt Social-Media-Consents aus JSON-String oder Datei"""
|
||||
if not input_value:
|
||||
return []
|
||||
|
||||
potential_path = Path(input_value)
|
||||
try:
|
||||
if potential_path.exists() and potential_path.is_file():
|
||||
content = potential_path.read_text(encoding='utf-8')
|
||||
else:
|
||||
content = input_value
|
||||
data = json.loads(content)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Ungültige Social-Media-Consents: {exc}") from exc
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Ein einzelnes Consent-Objekt erlauben
|
||||
return [data]
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("Social-Media-Consents müssen Liste oder Objekt sein")
|
||||
|
||||
return data
|
||||
|
||||
class ImageMetadataExtractor:
|
||||
"""Extrahiert Metadaten aus Bildern und Verzeichnissen
|
||||
|
||||
|
|
@ -78,7 +95,7 @@ class ImageMetadataExtractor:
|
|||
if not re.match(r'^(19|20)\d{2}$', jahr):
|
||||
self.logger.debug(f"Ungültiges Jahr in Pfad: {jahr}")
|
||||
# Versuche Jahr aus anderen Teilen zu extrahieren
|
||||
jahr = self.extract_year_from_path(file_path)
|
||||
jahr = self.extract_year_from_path(file_path) or ''
|
||||
|
||||
return {
|
||||
'jahr': jahr,
|
||||
|
|
@ -222,17 +239,97 @@ class ImageMetadataExtractor:
|
|||
class BatchUploader:
|
||||
"""Haupt-Klasse für Batch-Upload"""
|
||||
|
||||
def __init__(self, backend_url: str = DEFAULT_BACKEND_URL, user: str = None, password: str = None):
|
||||
self.backend_url = backend_url.rstrip('/')
|
||||
def __init__(self, backend_url: str = DEFAULT_BACKEND_URL,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None):
|
||||
self.base_url = backend_url.rstrip('/')
|
||||
if self.base_url.endswith('/api'):
|
||||
self.base_url = self.base_url[:-4]
|
||||
self.api_base_url = f"{self.base_url}/api"
|
||||
self.metadata_extractor = ImageMetadataExtractor()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Session für Connection-Reuse
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Batch-Uploader/1.0'
|
||||
'User-Agent': 'Batch-Uploader/2.0',
|
||||
'Accept': 'application/json'
|
||||
})
|
||||
self.auth = (user, password) if user and password else None
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.csrf_token: Optional[str] = None
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
path = path.lstrip('/')
|
||||
return f"{self.api_base_url}/{path}"
|
||||
|
||||
def _auth_required(self) -> bool:
|
||||
return bool(self.username and self.password)
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
return self._auth_required()
|
||||
|
||||
def ensure_admin_session(self) -> None:
|
||||
"""Public wrapper für Authentifizierung"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
def _ensure_authenticated(self) -> None:
|
||||
if self.csrf_token:
|
||||
return
|
||||
|
||||
if not self._auth_required():
|
||||
raise ValueError("Admin-Benutzername und Passwort erforderlich für Upload")
|
||||
|
||||
login_url = f"{self.base_url}/auth/login"
|
||||
self.logger.info("🔐 Melde Admin-Session an...")
|
||||
response = self.session.post(
|
||||
login_url,
|
||||
json={'username': self.username, 'password': self.password},
|
||||
timeout=20
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Login fehlgeschlagen ({response.status_code}): {response.text}"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("Login-Antwort konnte nicht gelesen werden") from exc
|
||||
self.csrf_token = data.get('csrfToken')
|
||||
if not self.csrf_token:
|
||||
self._refresh_csrf_token()
|
||||
else:
|
||||
self.logger.debug("CSRF-Token aus Login-Response übernommen")
|
||||
|
||||
def _refresh_csrf_token(self) -> None:
|
||||
csrf_url = f"{self.base_url}/auth/csrf-token"
|
||||
response = self.session.get(csrf_url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"CSRF-Token konnte nicht geladen werden ({response.status_code})"
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("CSRF-Antwort konnte nicht gelesen werden") from exc
|
||||
token = data.get('csrfToken')
|
||||
if not token:
|
||||
raise RuntimeError("Antwort enthielt kein csrfToken")
|
||||
|
||||
self.csrf_token = token
|
||||
self.logger.debug("CSRF-Token aktualisiert")
|
||||
|
||||
def _authorized_headers(self) -> Dict[str, str]:
|
||||
if not self.csrf_token:
|
||||
self._ensure_authenticated()
|
||||
if not self.csrf_token:
|
||||
raise RuntimeError("Kein CSRF-Token verfügbar")
|
||||
return {'X-CSRF-Token': self.csrf_token}
|
||||
|
||||
def scan_directory(self, directory: Path, recursive: bool = True) -> List[Path]:
|
||||
"""Scannt Verzeichnis nach unterstützten Bildern"""
|
||||
|
|
@ -305,6 +402,7 @@ class BatchUploader:
|
|||
def upload_batch(self, images: List[Path],
|
||||
default_titel: Optional[str] = None,
|
||||
default_name: Optional[str] = None,
|
||||
consents: Optional[Dict[str, Any]] = None,
|
||||
dry_run: bool = False) -> Dict:
|
||||
"""
|
||||
Uploaded Bilder gruppiert nach PROJEKTEN (Jahr/Name/Projekt)
|
||||
|
|
@ -317,6 +415,14 @@ class BatchUploader:
|
|||
if not images:
|
||||
return {'total': 0, 'successful': 0, 'failed': 0, 'failed_files': []}
|
||||
|
||||
consents_payload = consents.copy() if consents else {
|
||||
'workshopConsent': True,
|
||||
'socialMediaConsents': []
|
||||
}
|
||||
|
||||
if not consents_payload.get('workshopConsent'):
|
||||
raise ValueError('workshopConsent ist erforderlich für Batch-Uploads')
|
||||
|
||||
# 1. Bilder nach Projekten gruppieren
|
||||
project_groups = {}
|
||||
|
||||
|
|
@ -353,6 +459,9 @@ class BatchUploader:
|
|||
total_failed = 0
|
||||
failed_files = []
|
||||
|
||||
if not dry_run:
|
||||
self._ensure_authenticated()
|
||||
|
||||
for project_key, project_images in project_groups.items():
|
||||
self.logger.info(f"🚀 Upload Projekt '{project_key}': {len(project_images)} Bilder")
|
||||
|
||||
|
|
@ -381,12 +490,17 @@ class BatchUploader:
|
|||
)))
|
||||
|
||||
# Ein Upload-Request pro Projekt
|
||||
payload = {
|
||||
'metadata': json.dumps(backend_metadata),
|
||||
'consents': json.dumps(consents_payload)
|
||||
}
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.backend_url}/upload/batch",
|
||||
self._api_url('/upload/batch'),
|
||||
files=files,
|
||||
data={'metadata': json.dumps(backend_metadata)},
|
||||
timeout=120,
|
||||
auth=self.auth
|
||||
data=payload,
|
||||
headers=self._authorized_headers(),
|
||||
timeout=120
|
||||
)
|
||||
|
||||
# Files schließen
|
||||
|
|
@ -544,7 +658,7 @@ class BatchUploader:
|
|||
def test_connection(self) -> bool:
|
||||
"""Testet Verbindung zum Backend (mit optionaler Auth)"""
|
||||
try:
|
||||
response = self.session.get(f"{self.backend_url}/groups", timeout=10, auth=self.auth)
|
||||
response = self.session.get(self._api_url('/groups'), timeout=10)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
self.logger.error(f"Verbindungstest fehlgeschlagen: {e}")
|
||||
|
|
@ -586,10 +700,10 @@ Beispiele:
|
|||
default=DEFAULT_BACKEND_URL,
|
||||
help=f'Backend URL (Standard: {DEFAULT_BACKEND_URL})')
|
||||
|
||||
parser.add_argument('--user',
|
||||
help='HTTP Basic Auth Benutzername (optional)')
|
||||
parser.add_argument('--user', '--username', dest='username',
|
||||
help='Admin-Benutzername für Session-Login (erforderlich für Upload)')
|
||||
parser.add_argument('--password',
|
||||
help='HTTP Basic Auth Passwort (optional)')
|
||||
help='Admin-Passwort für Session-Login (erforderlich für Upload)')
|
||||
parser.add_argument('--no-recursive',
|
||||
action='store_true',
|
||||
help='Nicht rekursiv in Unterverzeichnisse')
|
||||
|
|
@ -598,6 +712,17 @@ Beispiele:
|
|||
action='store_true',
|
||||
help='Nur Analyse, kein Upload')
|
||||
|
||||
consent_group = parser.add_mutually_exclusive_group()
|
||||
consent_group.add_argument('--workshop-consent', dest='workshop_consent',
|
||||
action='store_true', default=True,
|
||||
help='Zustimmung zur Anzeige in der Werkstatt (Standard)')
|
||||
consent_group.add_argument('--no-workshop-consent', dest='workshop_consent',
|
||||
action='store_false',
|
||||
help='Keine Zustimmung zur Anzeige in der Werkstatt')
|
||||
|
||||
parser.add_argument('--social-media-consents',
|
||||
help='JSON (String oder Datei) mit Social-Media-Consents')
|
||||
|
||||
parser.add_argument('--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Verbose Output')
|
||||
|
|
@ -609,9 +734,24 @@ Beispiele:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
try:
|
||||
social_media_consents = load_social_media_consents(args.social_media_consents)
|
||||
except ValueError as exc:
|
||||
logger.error(str(exc))
|
||||
return 1
|
||||
|
||||
# Verzeichnis validieren
|
||||
directory = Path(args.directory).resolve()
|
||||
uploader = BatchUploader(args.backend, args.user, args.password)
|
||||
uploader = BatchUploader(args.backend, args.username, args.password)
|
||||
|
||||
consents_config = {
|
||||
'workshopConsent': args.workshop_consent,
|
||||
'socialMediaConsents': social_media_consents
|
||||
}
|
||||
|
||||
if not args.dry_run and not uploader.has_credentials():
|
||||
logger.error("Für Uploads werden Admin-Credentials benötigt (--user / --password)")
|
||||
return 1
|
||||
|
||||
# Verbindung testen (nur bei echtem Upload)
|
||||
if not args.dry_run:
|
||||
|
|
@ -620,6 +760,8 @@ Beispiele:
|
|||
logger.error("❌ Backend nicht erreichbar!")
|
||||
return 1
|
||||
logger.info("✅ Backend erreichbar")
|
||||
uploader.ensure_admin_session()
|
||||
logger.info("✅ Admin-Session aktiv")
|
||||
else:
|
||||
logger.info("🔍 Dry-Run Mode - Überspringe Verbindungstest")
|
||||
|
||||
|
|
@ -651,6 +793,7 @@ Beispiele:
|
|||
images,
|
||||
args.titel,
|
||||
args.name,
|
||||
consents_config,
|
||||
args.dry_run
|
||||
)
|
||||
|
||||
|
|
|
|||
280
scripts/create_admin_user.sh
Executable file
280
scripts/create_admin_user.sh
Executable file
|
|
@ -0,0 +1,280 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/create_admin_user.sh --username <name> --password <pass> [options]
|
||||
|
||||
Erstellt per HTTP-API entweder den initialen Admin (wenn noch keiner existiert)
|
||||
oder legt – nach Login mit bestehenden Admin-Zugangsdaten – zusätzliche Admins an.
|
||||
|
||||
Benötigte Tools: curl, jq
|
||||
|
||||
Optionen:
|
||||
--server <url> Backend-Basis-URL (Standard: http://localhost:5000)
|
||||
--username <name> Neuer Admin-Benutzername (Pflicht)
|
||||
--password <pass> Neues Admin-Passwort (Pflicht, min. 10 Zeichen)
|
||||
--role <role> Rolle für den Benutzer (Standard: admin)
|
||||
--require-password-change Markiert den Benutzer für Passwort-Änderung beim Login
|
||||
--admin-user <name> Bestehender Admin (für zusätzliche Benutzer erforderlich)
|
||||
--admin-password <pass> Passwort des bestehenden Admins
|
||||
--insecure TLS-Zertifikatsprüfung deaktivieren (z. B. bei Self-Signed)
|
||||
-h, --help Diese Hilfe anzeigen
|
||||
|
||||
Hinweis: Wenn bereits ein Admin existiert, müssen --admin-user und --admin-password
|
||||
angegeben werden, damit sich das Skript anmelden und den neuen Benutzer anlegen kann.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "ERROR: Benötigtes Tool '$1' wurde nicht gefunden." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Standardwerte
|
||||
SERVER_URL="${SERVER_URL:-${SERVER:-http://localhost:5000}}"
|
||||
TARGET_USERNAME=""
|
||||
TARGET_PASSWORD=""
|
||||
TARGET_ROLE="admin"
|
||||
REQUIRE_PASSWORD_CHANGE=false
|
||||
ADMIN_USERNAME="${ADMIN_USERNAME:-}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||
CURL_INSECURE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--server)
|
||||
SERVER_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--username)
|
||||
TARGET_USERNAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--password)
|
||||
TARGET_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--role)
|
||||
TARGET_ROLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--require-password-change)
|
||||
REQUIRE_PASSWORD_CHANGE=true
|
||||
shift
|
||||
;;
|
||||
--admin-user)
|
||||
ADMIN_USERNAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--admin-password)
|
||||
ADMIN_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--insecure)
|
||||
CURL_INSECURE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unbekannte Option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TARGET_USERNAME" || -z "$TARGET_PASSWORD" ]]; then
|
||||
echo "ERROR: --username und --password sind erforderlich." >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
BASE_URL="${SERVER_URL%/}"
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||
|
||||
CURL_EXTRA=()
|
||||
if [[ "$CURL_INSECURE" -eq 1 ]]; then
|
||||
CURL_EXTRA+=(-k)
|
||||
fi
|
||||
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY=""
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local data="$3"
|
||||
shift 3
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
local args=(-sS -o "$tmp" -w '%{http_code}' -X "$method" -H 'Accept: application/json' -b "$COOKIE_JAR" -c "$COOKIE_JAR")
|
||||
if [[ -n "$data" ]]; then
|
||||
args+=(-H 'Content-Type: application/json' -d "$data")
|
||||
fi
|
||||
if [[ ${#CURL_EXTRA[@]} -gt 0 ]]; then
|
||||
args+=("${CURL_EXTRA[@]}")
|
||||
fi
|
||||
if [[ $# -gt 0 ]]; then
|
||||
args+=("$@")
|
||||
fi
|
||||
local status
|
||||
if ! status=$(curl "${args[@]}" "$url"); then
|
||||
rm -f "$tmp"
|
||||
echo "ERROR: HTTP-Anfrage fehlgeschlagen (${method} ${url})" >&2
|
||||
exit 1
|
||||
fi
|
||||
HTTP_STATUS="$status"
|
||||
HTTP_BODY="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1"
|
||||
shift || true
|
||||
local matched=0
|
||||
IFS=',' read -ra codes <<< "$expected"
|
||||
for code in "${codes[@]}"; do
|
||||
if [[ "$HTTP_STATUS" == "$code" ]]; then
|
||||
matched=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $matched -eq 0 ]]; then
|
||||
local msg
|
||||
msg=$(echo "$HTTP_BODY" | jq -r '.error // .message // empty' 2>/dev/null || true)
|
||||
if [[ -z "$msg" ]]; then
|
||||
msg="$HTTP_BODY"
|
||||
fi
|
||||
echo "ERROR: Anfrage fehlgeschlagen (${HTTP_STATUS}): $msg" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
json_bool() {
|
||||
if [[ "$1" == "true" ]]; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
|
||||
jq_payload() {
|
||||
jq -n "$@"
|
||||
}
|
||||
|
||||
echo "INFO: Prüfe Setup-Status am ${BASE_URL}..."
|
||||
api_request "GET" "${BASE_URL}/auth/setup/status" ""
|
||||
assert_status "200"
|
||||
needs_setup=$(echo "$HTTP_BODY" | jq -r '.needsSetup // false')
|
||||
|
||||
if [[ "$needs_setup" == "true" ]]; then
|
||||
echo "INFO: Kein Admin vorhanden – erstelle initialen Benutzer..."
|
||||
payload=$(jq_payload --arg username "$TARGET_USERNAME" --arg password "$TARGET_PASSWORD" '{username:$username,password:$password}')
|
||||
api_request "POST" "${BASE_URL}/auth/setup/initial-admin" "$payload"
|
||||
assert_status "201"
|
||||
user=$(echo "$HTTP_BODY" | jq -r '.user.username')
|
||||
echo "SUCCESS: Initialer Admin '$user' erstellt."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "$ADMIN_USERNAME" || -z "$ADMIN_PASSWORD" ]]; then
|
||||
echo "ERROR: Bestehender Admin benötigt: bitte --admin-user und --admin-password angeben." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "INFO: Melde bestehenden Admin an..."
|
||||
login_payload=$(jq_payload --arg username "$ADMIN_USERNAME" --arg password "$ADMIN_PASSWORD" '{username:$username,password:$password}')
|
||||
api_request "POST" "${BASE_URL}/auth/login" "$login_payload"
|
||||
assert_status "200"
|
||||
|
||||
api_request "GET" "${BASE_URL}/auth/csrf-token" ""
|
||||
assert_status "200"
|
||||
csrf_token=$(echo "$HTTP_BODY" | jq -r '.csrfToken // empty')
|
||||
if [[ -z "$csrf_token" ]]; then
|
||||
echo "ERROR: Konnte keinen CSRF-Token abrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_flag=$(json_bool "$REQUIRE_PASSWORD_CHANGE")
|
||||
create_payload=$(jq_payload --arg username "$TARGET_USERNAME" --arg password "$TARGET_PASSWORD" --arg role "$TARGET_ROLE" --argjson requirePasswordChange "$require_flag" '{username:$username,password:$password,role:($role // "admin"),requirePasswordChange:$requirePasswordChange}')
|
||||
|
||||
echo "INFO: Lege zusätzlichen Admin an..."
|
||||
api_request "POST" "${BASE_URL}/api/admin/users" "$create_payload" -H "X-CSRF-Token: $csrf_token"
|
||||
assert_status "201"
|
||||
created_user=$(echo "$HTTP_BODY" | jq -r '.user.username')
|
||||
created_role=$(echo "$HTTP_BODY" | jq -r '.user.role')
|
||||
|
||||
echo "SUCCESS: Neuer Admin '${created_user}' (Rolle: ${created_role}) wurde erstellt."
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
BACKEND_DIR="${REPO_ROOT}/backend"
|
||||
NODE_SCRIPT="${BACKEND_DIR}/src/scripts/createAdminUser.js"
|
||||
STAMP_FILE="${BACKEND_DIR}/.create_admin_user_deps.stamp"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ./scripts/create_admin_user.sh --username <name> --password <pass> [--role <role>] [--require-password-change]
|
||||
|
||||
Beispiele:
|
||||
./scripts/create_admin_user.sh --username admin2 --password 'SehrSicher123!'
|
||||
./scripts/create_admin_user.sh --username audit --password 'NochSicherer123!' --role auditor --require-password-change
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -f "${NODE_SCRIPT}" ]]; then
|
||||
echo "createAdminUser.js nicht gefunden (erwartet unter ${NODE_SCRIPT})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "Node.js wird benötigt, bitte installieren." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_node_modules() {
|
||||
if [[ ! -d "${BACKEND_DIR}/node_modules" ]]; then
|
||||
echo "📦 Installiere Backend-Abhängigkeiten (npm install)..."
|
||||
(cd "${BACKEND_DIR}" && npm install)
|
||||
touch "${STAMP_FILE}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "${BACKEND_DIR}/package-lock.json" ]] && [[ ! -f "${STAMP_FILE}" || "${BACKEND_DIR}/package-lock.json" -nt "${STAMP_FILE}" ]]; then
|
||||
echo "📦 Aktualisiere Backend-Abhängigkeiten (npm install)..."
|
||||
(cd "${BACKEND_DIR}" && npm install)
|
||||
touch "${STAMP_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_node_modules
|
||||
|
||||
pushd "${BACKEND_DIR}" >/dev/null
|
||||
# Standardmäßig teueres Preview-Rendering überspringen
|
||||
export SKIP_PREVIEW_GENERATION="${SKIP_PREVIEW_GENERATION:-1}"
|
||||
node "${NODE_SCRIPT}" "$@"
|
||||
popd >/dev/null
|
||||
14
scripts/examples.sh
Executable file → Normal file
14
scripts/examples.sh
Executable file → Normal file
|
|
@ -15,16 +15,11 @@ echo "curl http://localhost:5000/api/groups"
|
|||
|
||||
# Beispiel 1: Einfacher Upload
|
||||
echo -e "\n2. Einfacher Upload:"
|
||||
echo "python batch_uploader.py ./test_images --titel \"Test Sammlung\""
|
||||
echo "python batch_uploader.py ./test_images --titel \"Test Sammlung\" --user admin --password 'SehrSicher123!'"
|
||||
|
||||
# Beispiel 2: Erweiterte Optionen
|
||||
echo -e "\n3. Mit allen Optionen:"
|
||||
echo "python batch_uploader.py /home/user/photos \\"
|
||||
echo " --titel \"Urlaubsbilder 2024\" \\"
|
||||
echo " --name \"Max Mustermann\" \\"
|
||||
echo " --backend http://localhost:5000 \\"
|
||||
echo " --chunk-size 10 \\"
|
||||
echo " --verbose"
|
||||
echo "python batch_uploader.py /home/user/photos --titel \"Urlaubsbilder 2024\" --name \"Max Mustermann\" --backend http://localhost:5000 --user admin --password 'SehrSicher123!' --social-media-consents consents.json --verbose"
|
||||
|
||||
# Beispiel 3: Dry Run
|
||||
echo -e "\n4. Dry Run (Analyse ohne Upload):"
|
||||
|
|
@ -32,10 +27,7 @@ echo "python batch_uploader.py ./images --dry-run --verbose"
|
|||
|
||||
# Beispiel 4: Große Sammlung
|
||||
echo -e "\n5. Große Sammlung optimiert:"
|
||||
echo "python batch_uploader.py /massive/photo/archive \\"
|
||||
echo " --titel \"Foto Archiv\" \\"
|
||||
echo " --chunk-size 3 \\"
|
||||
echo " --verbose"
|
||||
echo "python batch_uploader.py /massive/photo/archive --titel \"Foto Archiv\" --user admin --password 'SehrSicher123!' --verbose"
|
||||
|
||||
# Test-Verzeichnis erstellen
|
||||
echo -e "\n6. Test-Verzeichnis erstellen:"
|
||||
|
|
|
|||
|
|
@ -121,15 +121,16 @@ def run_test_commands():
|
|||
print(f"cd {Path.cwd()}")
|
||||
print()
|
||||
|
||||
credentials = "--user admin --password 'SehrSicher123!'"
|
||||
commands = [
|
||||
"# 1. Dry-Run Test (Neue Struktur)",
|
||||
f"python3 batch_uploader.py {TEST_DIR} --dry-run --verbose",
|
||||
"",
|
||||
"# 2. Einzelnes Projekt testen",
|
||||
f"python3 batch_uploader.py {TEST_DIR}/2024/Max_Mustermann/Urlaub_Mallorca --titel \"Mallorca Test\" --chunk-size 2",
|
||||
f"python3 batch_uploader.py {TEST_DIR}/2024/Max_Mustermann/Urlaub_Mallorca --titel \"Mallorca Test\" {credentials}",
|
||||
"",
|
||||
"# 3. Vollständiger Upload (Neue Struktur)",
|
||||
f"python3 batch_uploader.py {TEST_DIR} --titel \"Test Sammlung\" --name \"Test User\" --verbose",
|
||||
f"python3 batch_uploader.py {TEST_DIR} --titel \"Test Sammlung\" --name \"Test User\" --verbose {credentials}",
|
||||
"",
|
||||
"# 4. Backend Status prüfen",
|
||||
"curl http://localhost:5000/api/groups",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user