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:
Matthias Lotz 2025-11-23 21:18:42 +01:00
parent fb4b3b95a6
commit 6332b82c6a
73 changed files with 4725 additions and 1837 deletions

View File

@ -4,10 +4,10 @@
Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unterschiedliche Zugriffslevel: 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) - **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
- **Methode**: Bearer Token im Authorization Header - **Methode**: HTTP Session (Cookie) + CSRF-Token
- **Konfiguration**: `.env``ADMIN_API_KEY` - **Konfiguration**: `.env``ADMIN_SESSION_SECRET` (+ Admin-Benutzer in DB)
### 2. Management-Routes (UUID Token) ### 2. Management-Routes (UUID Token)
- **Zweck**: Self-Service Portal für Gruppen-Verwaltung - **Zweck**: Self-Service Portal für Gruppen-Verwaltung
@ -20,36 +20,54 @@ Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unte
### Setup ### Setup
1. **Sicheren Admin-Key generieren**: 1. **Session Secret setzen**:
```bash
# Linux/Mac:
openssl rand -hex 32
# Oder Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
2. **In `.env` eintragen**:
```env ```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 ### 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 ```bash
curl -H "Authorization: Bearer dein-generierter-key-hier" \ CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
http://localhost:5000/api/admin/deletion-log 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**: ### Geschützte Endpoints (Auszug)
- Type: `Bearer Token`
- Token: `dein-generierter-key-hier`
### Geschützte Endpoints
| Endpoint | Method | Beschreibung | | 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/run` | POST | Cleanup manuell starten |
| `/api/admin/cleanup/status` | GET | Cleanup Status | | `/api/admin/cleanup/status` | GET | Cleanup Status |
| `/api/admin/rate-limiter/stats` | GET | Rate-Limiter Statistiken | | `/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` | 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/admin/groups/:id` | DELETE | Gruppe löschen |
| `/api/system/migration/*` | POST | Migrationswerkzeuge |
### Error Codes ### Error Codes
| Status | Bedeutung | | Status | Bedeutung |
|--------|-----------| |--------|-----------|
| `403` | Authorization header fehlt oder ungültig | | `401` | Session fehlt oder ist abgelaufen |
| `500` | ADMIN_API_KEY nicht konfiguriert | | `403` | CSRF ungültig oder Benutzer hat keine Admin-Rolle |
| `419` | (optional) Session wurde invalidiert |
--- ---
@ -147,42 +166,43 @@ npm test
### Manuelles Testen ### Manuelles Testen
**Admin-Route ohne Auth**: 1. **Login**:
```bash ```bash
curl http://localhost:5000/api/admin/deletion-log curl -c cookies.txt -X POST -H "Content-Type: application/json" \
# → 403 Forbidden -d '{"username":"admin","password":"Secret123"}' \
``` http://localhost:5000/auth/login
```
**Admin-Route mit Auth**: 2. **CSRF holen**:
```bash ```bash
curl -H "Authorization: Bearer your-key" \ CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
http://localhost:5000/api/admin/deletion-log ```
# → 200 OK 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 ## 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`) - [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
- [ ] HTTPS verwenden (TLS/SSL) - [ ] HTTPS verwenden (TLS/SSL) damit Cookies `Secure` gesetzt werden können
- [ ] Rate Limiting aktiviert prüfen - [ ] Session-Store auf persistentem Volume ablegen
- [ ] Audit Logs regelmäßig überprüfen - [ ] Rate Limiting & Audit Logs überwachen
- [ ] Token-Rotation Policy für Admin-Key implementieren - [ ] Admin-Benutzerverwaltung (On-/Offboarding) dokumentieren
--- ---
## Sicherheits-Hinweise ## Sicherheits-Hinweise
### Admin-Key Rotation ### Session-Secret Rotation
Admin-Key regelmäßig erneuern (z.B. alle 90 Tage): 1. Wartungsfenster planen (alle Sessions werden invalidiert)
2. Neuen `ADMIN_SESSION_SECRET` generieren
1. Neuen Key generieren 3. `.env` aktualisieren und Backend neu starten
2. `.env` aktualisieren
3. Server neu starten
4. Alte Clients auf neuen Key umstellen
### Management-Token ### Management-Token
@ -192,8 +212,8 @@ Admin-Key regelmäßig erneuern (z.B. alle 90 Tage):
### Best Practices ### Best Practices
- Admin-Key **nie** im Code hart-kodieren - Keine Admin-Secrets im Frontend oder in Repos committen
- Admin-Key **nie** in Logs/Errors ausgeben - Admin-Session-Cookies nur über HTTPS ausliefern
- Requests über HTTPS (kein Plain-HTTP in Production) - Rate-Limiting für beide Auth-Typen aktiv halten
- Rate-Limiting für beide Auth-Typen aktiv
- Audit-Logs regelmäßig auf Anomalien prüfen - Audit-Logs regelmäßig auf Anomalien prüfen
- Session-Store-Backups schützen (enthalten Benutzer-IDs)

View File

@ -1,5 +1,27 @@
# Changelog # 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 ## [Unreleased] - Branch: feature/SocialMedia
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025) ### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
@ -56,7 +78,7 @@
- ✅ **OpenAPI Auto-Generation**: - ✅ **OpenAPI Auto-Generation**:
- Automatic spec generation on backend start (dev mode) - 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 - Skip generation in test and production modes
#### Bug Fixes #### Bug Fixes

View 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.

View File

@ -3,6 +3,13 @@ Feature Request: Server-seitige Session-Authentifizierung für Admin-API
Zielgruppe: Entwickler / KI-Implementierer 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 # FEATURE_REQUEST: Security — Server-seitige Sessions für Admin-API
Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler) Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler)

View 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.

View File

@ -8,7 +8,7 @@
1. ✅ **OpenAPI Auto-Generation:** Swagger Spec wird automatisch aus Route-Definitionen generiert 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 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 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 5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing
6. ✅ **API Security:** Bearer Token Authentication für Admin-Endpoints 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 # 4. Tests schreiben: tests/api/newRoute.test.js
npm test npm test
# 5. Swagger UI: http://localhost:5001/api/docs # 5. Swagger UI: http://localhost:5001/api/docs/
``` ```
--- ---

View File

@ -21,7 +21,7 @@
## Anforderungen an das Feature ## Anforderungen an das Feature
1. Beim lokalen DevStart soll eine OpenAPI Spec erzeugt werden (z. B. mit `swagger-autogen` oder `express-oas-generator`). 1. Beim lokalen DevStart 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. 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 ProduktionsStartverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per optin env var. 4. Keine Breaking Changes am ProduktionsStartverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per optin env var.
5. Erzeugte Spec soll ins Repo (z. B. `docs/openapi.json`) optional geschrieben werden können (für CI/Review). 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) ## Minimaler Scope (MVP)
- Devonly Integration: Generator installiert und beim Start einmal ausgeführt. - Devonly 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. - Kurze Anleitung im `README.dev.md` wie man die Doku lokal öffnet.
--- ---

View File

@ -6,7 +6,7 @@
Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen: 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`) - **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) - **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) - **Backend**: http://localhost:5001 (API)
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI) - **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
- **Slideshow**: http://localhost:3000/slideshow - **Slideshow**: http://localhost:3000/slideshow
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth) - **Moderation**: http://localhost:3000/moderation (Login über Admin Session)
### Logs verfolgen ### Logs verfolgen
```bash ```bash
@ -55,7 +55,7 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich ### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich
**Massive API-Änderungen im November 2025:** **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`) - Route-Pfade umstrukturiert (siehe `routeMappings.js`)
- Neue Error-Response-Formate - Neue Error-Response-Formate
@ -72,7 +72,7 @@ Siehe auch: **`backend/src/routes/README.md`** für vollständige API-Übersicht
**Wichtige Route-Gruppen:** **Wichtige Route-Gruppen:**
- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints - `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints
- `/api/manage/:token` - Self-Service Management Portal (UUID-Token) - `/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 - `/api/system/migration/*` - Datenbank-Migrationen
**⚠️ Express Route-Reihenfolge beachten:** **⚠️ Express Route-Reihenfolge beachten:**
@ -91,14 +91,28 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
**Zwei Auth-Systeme parallel:** **Zwei Auth-Systeme parallel:**
1. **Admin API (Bearer Token)**: 1. **Admin API (Session + CSRF)**:
```bash ```bash
# .env konfigurieren: # .env konfigurieren:
ADMIN_API_KEY=your-secure-key-here ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
# API-Aufrufe: # Initialen Admin anlegen (falls benötigt)
curl -H "Authorization: Bearer your-secure-key-here" \ curl -c cookies.txt http://localhost:5001/auth/setup/status
http://localhost:5001/api/admin/groups 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
``` ```
2. **Management Portal (UUID Token)**: 2. **Management Portal (UUID Token)**:
@ -109,13 +123,22 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
📖 **Vollständige Doku**: `AUTHENTICATION.md` 📖 **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 ### OpenAPI-Spezifikation
Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert: Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert:
```bash ```bash
# Generiert: backend/docs/openapi.json # Generiert: backend/docs/openapi.json
# Swagger UI: http://localhost:5001/api/docs # Swagger UI: http://localhost:5001/api/docs/
# Manuelle Generierung: # Manuelle Generierung:
cd backend cd backend
@ -157,7 +180,8 @@ router.get('/example', async (req, res) => {
- `repositories/GroupRepository.js` - Consent-Management & CRUD - `repositories/GroupRepository.js` - Consent-Management & CRUD
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung - `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
- `routes/batchUpload.js` - Upload mit Consent-Validierung - `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 - `database/DatabaseManager.js` - Automatische Migrationen
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik - `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik

View File

@ -32,13 +32,13 @@ This project extends the original [Image-Uploader by vallezw](https://github.com
- Test execution time: ~10 seconds for full suite - Test execution time: ~10 seconds for full suite
- CI/CD ready with proper teardown and cleanup - CI/CD ready with proper teardown and cleanup
- **🔒 Admin API Authentication** (Nov 16): - **🔒 Admin Session Authentication** (Nov 16):
- Bearer token authentication for all admin endpoints - Server-managed HTTP sessions for all admin/system endpoints
- Secure ADMIN_API_KEY environment variable configuration - 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` - 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` - Complete authentication documentation in `AUTHENTICATION.md`
- Ready for production deployment with token rotation support
- **📋 API Route Documentation** (Nov 16): - **📋 API Route Documentation** (Nov 16):
- Single Source of Truth: `backend/src/routes/routeMappings.js` - 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) ### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication) - **Access**: `http://localhost/moderation` (requires admin session)
- **Authentication Methods**: - **Authentication Flow**:
- **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup) - Built-in login form establishes a server session stored in HttpOnly cookies
- **API Direct Access**: Bearer Token via `Authorization: Bearer <ADMIN_API_KEY>` header - First-time setup wizard creates the initial admin user once `ADMIN_SESSION_SECRET` is configured
- See `AUTHENTICATION.md` for detailed setup instructions - 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 - **Protected Endpoints**: All `/api/admin/*` routes require authentication
- **Features**: - **Features**:
- Review pending image groups before public display - Review pending image groups before public display

16
TODO.md
View File

@ -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] In der angezeigten Gruppen soll statt Bilder ansehen Gruppe editieren stehen
[x] Diese bestehende Ansicht (Bilder ansehen) soll als eigene Seite implementiert werden [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 [x] Ergänzung der Möglichkeit eine Beschreibung zu den Bildern hinzuzufügen
[ ] Erweiterung der ModerationPage um reine Datenbankeditor der sqlite Datenbank.
## 🚀 Deployment-Überlegungen ## 🚀 Deployment-Überlegungen
@ -98,16 +98,16 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
- ✅ Mobile-Kompatibilität - ✅ Mobile-Kompatibilität
### Nice-to-Have ### Nice-to-Have
- 🎨 Drag & Drop Reihenfolge ändern [x] 🎨 Drag & Drop Reihenfolge ändern
- 📊 Upload-Progress mit Details [x] 📊 Upload-Progress mit Details
- 🖼️ Thumbnail-Navigation in Slideshow [x] 🖼️ Thumbnail-Navigation in Slideshow
- 🔄 Batch-Operations (alle entfernen, etc.) [ ] 🔄 Batch-Operations (alle entfernen, etc.)
### Future Features ### Future Features
- 👤 User-Management - 👤 User-Management
- 🏷️ Tagging-System
- 📤 Export-Funktionen
- 🎵 Audio-Integration
--- ---

View File

@ -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

View File

@ -1,6 +1,7 @@
module.exports = { module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
setupFiles: ['<rootDir>/tests/env.js'],
collectCoverageFrom: [ collectCoverageFrom: [
'src/**/*.js', 'src/**/*.js',
'!src/index.js', // Server startup '!src/index.js', // Server startup

View File

@ -5,7 +5,7 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "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": "npm run dev --prefix ../frontend",
"client-build": "cd ../frontend && npm run build && serve -s build -l 80", "client-build": "cd ../frontend && npm run build && serve -s build -l 80",
"dev": "concurrently \"npm run server\" \"npm run client\"", "dev": "concurrently \"npm run server\" \"npm run client\"",
@ -15,15 +15,19 @@
"validate-openapi": "redocly lint docs/openapi.json", "validate-openapi": "redocly lint docs/openapi.json",
"test": "jest --coverage", "test": "jest --coverage",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:api": "jest tests/api" "test:api": "jest tests/api",
"create-admin": "node src/scripts/createAdminUser.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"connect-sqlite3": "^0.9.16",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"express-session": "^1.18.2",
"find-remove": "^2.0.3", "find-remove": "^2.0.3",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

@ -5,18 +5,22 @@ const fs = require('fs');
class DatabaseManager { class DatabaseManager {
constructor() { constructor() {
this.db = null; this.db = null;
// Use in-memory database for tests, file-based for production this.dbPath = null;
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.schemaPath = path.join(__dirname, 'schema.sql'); 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() { async initialize() {
try { try {
if (!this.dbPath) {
this.dbPath = this.getDatabasePath();
}
// Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory) // Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
if (this.dbPath !== ':memory:') { if (this.dbPath !== ':memory:') {
const dataDir = path.dirname(this.dbPath); const dataDir = path.dirname(this.dbPath);
@ -47,8 +51,10 @@ class DatabaseManager {
// Run database migrations (automatic on startup) // Run database migrations (automatic on startup)
await this.runMigrations(); await this.runMigrations();
// Generate missing previews for existing images (skip in test mode) const skipPreviewGeneration = ['1', 'true', 'yes'].includes(String(process.env.SKIP_PREVIEW_GENERATION || '').toLowerCase());
if (process.env.NODE_ENV !== 'test') {
// Generate missing previews for existing images (skip in test mode or when explicitly disabled)
if (process.env.NODE_ENV !== 'test' && !skipPreviewGeneration) {
await this.generateMissingPreviews(); await this.generateMissingPreviews();
} }
@ -168,6 +174,31 @@ class DatabaseManager {
`); `);
console.log('✓ Trigger erstellt'); 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'); console.log('✅ Datenbank-Schema vollständig erstellt');
} catch (error) { } catch (error) {
console.error('💥 Fehler beim Erstellen des Schemas:', 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 // Promise-wrapper für sqlite3.get
get(sql, params = []) { get(sql, params = []) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -373,29 +417,27 @@ class DatabaseManager {
// Execute migration in a transaction // Execute migration in a transaction
await this.run('BEGIN 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 const cleanedSql = sql
.split('\n') .split('\n')
.map(line => { .map(line => {
// Remove inline comments (everything after --)
const commentIndex = line.indexOf('--'); const commentIndex = line.indexOf('--');
if (commentIndex !== -1) { if (commentIndex !== -1) {
return line.substring(0, commentIndex); return line.substring(0, commentIndex);
} }
return line; return line;
}) })
.join('\n'); .join('\n')
.trim();
// Split by semicolon and execute each statement if (!cleanedSql) {
const statements = cleanedSql console.warn(` ⚠️ Migration ${file} enthält keinen ausführbaren SQL-Code, übersprungen`);
.split(';') await this.run('COMMIT');
.map(s => s.trim()) continue;
.filter(s => s.length > 0);
for (const statement of statements) {
await this.run(statement);
} }
await this.exec(cleanedSql);
// Record migration // Record migration
await this.run( await this.run(
'INSERT INTO schema_migrations (migration_name) VALUES (?)', 'INSERT INTO schema_migrations (migration_name) VALUES (?)',

View 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;

View File

@ -48,3 +48,25 @@ FOR EACH ROW
BEGIN BEGIN
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END; 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;

View File

@ -1,49 +1,19 @@
/** /**
* Admin Authentication Middleware * 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 requireAdminAuth = (req, res, next) => {
const authHeader = req.headers.authorization; const sessionUser = req.session && req.session.user;
// Check if Authorization header exists if (!sessionUser || sessionUser.role !== 'admin') {
if (!authHeader) {
return res.status(403).json({ return res.status(403).json({
error: 'Zugriff verweigert', error: 'Zugriff verweigert',
message: 'Authorization header fehlt' reason: 'SESSION_REQUIRED'
}); });
} }
// Check if it's a Bearer token res.locals.adminUser = sessionUser;
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
next(); next();
}; };

View 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 };

View File

@ -1,10 +1,12 @@
const express = require("express"); const express = require("express");
const fileUpload = require("express-fileupload"); const fileUpload = require("express-fileupload");
const cors = require("./cors"); const cors = require("./cors");
const session = require("./session");
const applyMiddlewares = (app) => { const applyMiddlewares = (app) => {
app.use(fileUpload()); app.use(fileUpload());
app.use(cors); app.use(cors);
app.use(session);
// JSON Parser für PATCH/POST Requests // JSON Parser für PATCH/POST Requests
app.use(express.json()); app.use(express.json());
}; };

View 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;

View 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();

View File

@ -114,7 +114,7 @@ npm run generate-openapi
**Generiert:** `backend/docs/openapi.json` **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? ### Was wird generiert?
@ -321,7 +321,7 @@ npm run test-openapi
### Swagger UI öffnen ### Swagger UI öffnen
``` ```
http://localhost:5000/api/docs http://localhost:5001/api/docs/
``` ```
**Hinweis:** Nur im Development-Modus verfügbar! **Hinweis:** Nur im Development-Modus verfügbar!

View File

@ -4,14 +4,77 @@ const DeletionLogRepository = require('../repositories/DeletionLogRepository');
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository'); const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
const GroupRepository = require('../repositories/GroupRepository'); const GroupRepository = require('../repositories/GroupRepository');
const GroupCleanupService = require('../services/GroupCleanupService'); const GroupCleanupService = require('../services/GroupCleanupService');
const AdminAuthService = require('../services/AdminAuthService');
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter'); const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
const { requireAdminAuth } = require('../middlewares/auth'); const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
// GroupCleanupService ist bereits eine Instanz, keine Klasse // GroupCleanupService ist bereits eine Instanz, keine Klasse
const cleanupService = GroupCleanupService; const cleanupService = GroupCleanupService;
// Apply admin authentication to ALL routes in this router // Apply admin authentication to ALL routes in this router
router.use(requireAdminAuth); 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) => { router.get('/deletion-log', async (req, res) => {
/* /*

165
backend/src/routes/auth.js Normal file
View 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;

View File

@ -10,9 +10,11 @@ const GroupRepository = require('../repositories/GroupRepository');
const SocialMediaRepository = require('../repositories/SocialMediaRepository'); const SocialMediaRepository = require('../repositories/SocialMediaRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth'); const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
// Schütze alle Consent-Routes mit Admin-Auth // Schütze alle Consent-Routes mit Admin-Auth
router.use(requireAdminAuth); router.use(requireAdminAuth);
router.use(requireCsrf);
// ============================================================================ // ============================================================================
// Social Media Platforms // Social Media Platforms

View File

@ -1,7 +1,9 @@
const authRouter = require('./auth');
const uploadRouter = require('./upload'); const uploadRouter = require('./upload');
const downloadRouter = require('./download'); const downloadRouter = require('./download');
const batchUploadRouter = require('./batchUpload'); const batchUploadRouter = require('./batchUpload');
const groupsRouter = require('./groups'); const groupsRouter = require('./groups');
const socialMediaRouter = require('./socialMedia');
const migrationRouter = require('./migration'); const migrationRouter = require('./migration');
const reorderRouter = require('./reorder'); const reorderRouter = require('./reorder');
const adminRouter = require('./admin'); const adminRouter = require('./admin');
@ -13,10 +15,12 @@ const routeMappingsConfig = require('./routeMappings');
// Map router names to actual router instances // Map router names to actual router instances
const routerMap = { const routerMap = {
auth: authRouter,
upload: uploadRouter, upload: uploadRouter,
download: downloadRouter, download: downloadRouter,
batchUpload: batchUploadRouter, batchUpload: batchUploadRouter,
groups: groupsRouter, groups: groupsRouter,
socialMedia: socialMediaRouter,
migration: migrationRouter, migration: migrationRouter,
reorder: reorderRouter, reorder: reorderRouter,
admin: adminRouter, admin: adminRouter,

View File

@ -3,6 +3,7 @@ const { Router } = require('express');
const MigrationService = require('../services/MigrationService'); const MigrationService = require('../services/MigrationService');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth'); const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
const router = Router(); const router = Router();
@ -35,7 +36,7 @@ router.get('/status', async (req, res) => {
}); });
// Protect dangerous migration operations with admin auth // 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.tags = ['System Migration']
#swagger.summary = 'Manually trigger 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.tags = ['System Migration']
#swagger.summary = 'Rollback to JSON' #swagger.summary = 'Rollback to JSON'

View File

@ -1,6 +1,11 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository'); const GroupRepository = require('../repositories/GroupRepository');
const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
router.use(requireAdminAuth);
router.use(requireCsrf);
/** /**
* @swagger * @swagger

View File

@ -6,11 +6,15 @@
*/ */
module.exports = [ module.exports = [
// Auth API - Session & CSRF Management
{ router: 'auth', prefix: '/auth', file: 'auth.js' },
// Public API - Öffentlich zugänglich // Public API - Öffentlich zugänglich
{ router: 'upload', prefix: '/api', file: 'upload.js' }, { router: 'upload', prefix: '/api', file: 'upload.js' },
{ router: 'download', prefix: '/api', file: 'download.js' }, { router: 'download', prefix: '/api', file: 'download.js' },
{ router: 'batchUpload', prefix: '/api', file: 'batchUpload.js' }, { router: 'batchUpload', prefix: '/api', file: 'batchUpload.js' },
{ router: 'groups', prefix: '/api', file: 'groups.js' }, { router: 'groups', prefix: '/api', file: 'groups.js' },
{ router: 'socialMedia', prefix: '/api', file: 'socialMedia.js' },
// Management API - Token-basierter Zugriff // Management API - Token-basierter Zugriff
{ router: 'management', prefix: '/api/manage', file: 'management.js' }, { router: 'management', prefix: '/api/manage', file: 'management.js' },

View 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;

View 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);
}
}
})();

View File

@ -1,29 +1,17 @@
const express = require('express'); const express = require('express');
const fs = require('fs');
const path = require('path');
const initiateResources = require('./utils/initiate-resources'); const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager'); const dbManager = require('./database/DatabaseManager');
const SchedulerService = require('./services/SchedulerService'); const SchedulerService = require('./services/SchedulerService');
const generateOpenApi = require('./generate-openapi');
// Dev: Auto-generate OpenAPI spec on server start (skip in test mode) // Dev: Swagger UI (mount only in non-production) — require lazily
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { let swaggerUi = null;
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;
try { try {
// require lazily — only available/used in dev
swaggerUi = require('swagger-ui-express'); swaggerUi = require('swagger-ui-express');
swaggerDocument = require('../docs/openapi.json');
} catch (e) { } catch (e) {
// ignore if not installed or file missing
swaggerUi = null; swaggerUi = null;
swaggerDocument = null;
} }
class Server { class Server {
@ -35,8 +23,35 @@ class Server {
this._app = express(); 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() { async start() {
try { try {
await this.generateOpenApiSpecIfNeeded();
// Initialisiere Datenbank // Initialisiere Datenbank
console.log('🔄 Initialisiere Datenbank...'); console.log('🔄 Initialisiere Datenbank...');
await dbManager.initialize(); await dbManager.initialize();
@ -48,9 +63,12 @@ class Server {
this._app.use('/api/previews', express.static( __dirname + '/data/previews')); this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
// Mount Swagger UI in dev only when available // Mount Swagger UI in dev only when available
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { if (process.env.NODE_ENV !== 'production' && swaggerUi) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); const swaggerDocument = this.loadSwaggerDocument();
console.log(' Swagger UI mounted at /api/docs (dev only)'); 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, () => { this._app.listen(this._port, () => {
console.log(`✅ Server läuft auf Port ${this._port}`); console.log(`✅ Server läuft auf Port ${this._port}`);

View 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();

View File

@ -1,55 +1,34 @@
const { getRequest } = require('../testServer'); const { getRequest } = require('../testServer');
const { getAdminSession } = require('../utils/adminSession');
describe('Admin Auth Middleware', () => { describe('Admin Auth Middleware', () => {
describe('Without Auth Token', () => { describe('Without Session', () => {
it('should reject requests without Authorization header', async () => { it('should reject requests without session cookie', async () => {
const response = await getRequest() const response = await getRequest()
.get('/api/admin/deletion-log') .get('/api/admin/deletion-log')
.expect(403); .expect(403);
expect(response.body).toHaveProperty('error'); 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()
.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', () => { describe('With Valid Session', () => {
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-123'; let adminSession;
beforeAll(() => { beforeAll(async () => {
// Set test admin key adminSession = await getAdminSession();
process.env.ADMIN_API_KEY = validToken;
}); });
it('should allow access with valid Bearer token', async () => { it('should allow access with valid session', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/deletion-log') .get('/api/admin/deletion-log')
.set('Authorization', `Bearer ${validToken}`)
.expect(200); .expect(200);
expect(response.body).toHaveProperty('success'); expect(response.body).toHaveProperty('success');
}); });
it('should protect all admin endpoints', async () => { it('should allow access to multiple admin endpoints', async () => {
const endpoints = [ const endpoints = [
'/api/admin/deletion-log', '/api/admin/deletion-log',
'/api/admin/rate-limiter/stats', '/api/admin/rate-limiter/stats',
@ -58,9 +37,8 @@ describe('Admin Auth Middleware', () => {
]; ];
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
const response = await getRequest() const response = await adminSession.agent
.get(endpoint) .get(endpoint)
.set('Authorization', `Bearer ${validToken}`)
.expect(200); .expect(200);
expect(response.body).toBeDefined(); expect(response.body).toBeDefined();

View File

@ -29,7 +29,7 @@ describe('Admin API - Security', () => {
.get('/api/admin/deletion-log') .get('/api/admin/deletion-log')
.expect(403); .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 () => { it('should validate query parameters with authorization', async () => {
// This test would need a valid admin token // This test would require a logged-in admin session
// For now, we just test that invalid params are rejected // For now, we just ensure the endpoint rejects unauthenticated access
await getRequest() await getRequest()
.get('/api/admin/groups?status=invalid_status') .get('/api/admin/groups?status=invalid_status')
.expect(403); // Still 403 without auth, but validates endpoint exists .expect(403); // Still 403 without auth, but validates endpoint exists

View File

@ -1,13 +1,17 @@
const { getRequest } = require('../testServer'); const { getRequest } = require('../testServer');
const { getAdminSession } = require('../utils/adminSession');
describe('Consent Management API', () => { 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', () => { describe('GET /api/admin/social-media/platforms', () => {
it('should return list of social media platforms', async () => { it('should return list of social media platforms', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/social-media/platforms') .get('/api/admin/social-media/platforms')
.set('Authorization', `Bearer ${validToken}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
@ -15,9 +19,8 @@ describe('Consent Management API', () => {
}); });
it('should include platform metadata', async () => { it('should include platform metadata', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/social-media/platforms') .get('/api/admin/social-media/platforms');
.set('Authorization', `Bearer ${validToken}`);
if (response.body.length > 0) { if (response.body.length > 0) {
const platform = response.body[0]; const platform = response.body[0];
@ -30,16 +33,14 @@ describe('Consent Management API', () => {
describe('GET /api/admin/groups/:groupId/consents', () => { describe('GET /api/admin/groups/:groupId/consents', () => {
it('should return 404 for non-existent group', async () => { it('should return 404 for non-existent group', async () => {
await getRequest() await adminSession.agent
.get('/api/admin/groups/non-existent-group/consents') .get('/api/admin/groups/non-existent-group/consents')
.set('Authorization', `Bearer ${validToken}`)
.expect(404); .expect(404);
}); });
it('should reject path traversal attempts', async () => { it('should reject path traversal attempts', async () => {
await getRequest() await adminSession.agent
.get('/api/admin/groups/../../../etc/passwd/consents') .get('/api/admin/groups/../../../etc/passwd/consents')
.set('Authorization', `Bearer ${validToken}`)
.expect(404); .expect(404);
}); });
}); });
@ -53,9 +54,9 @@ describe('Consent Management API', () => {
}); });
it('should require valid consent data with auth', async () => { 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') .post('/api/admin/groups/test-group-id/consents')
.set('Authorization', `Bearer ${validToken}`) .set('X-CSRF-Token', adminSession.csrfToken)
.send({}) .send({})
.expect(400); .expect(400);
@ -65,9 +66,8 @@ describe('Consent Management API', () => {
describe('GET /api/admin/groups/by-consent', () => { describe('GET /api/admin/groups/by-consent', () => {
it('should return filtered groups', async () => { it('should return filtered groups', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/groups/by-consent') .get('/api/admin/groups/by-consent')
.set('Authorization', `Bearer ${validToken}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
@ -77,9 +77,8 @@ describe('Consent Management API', () => {
}); });
it('should accept platform filter', async () => { it('should accept platform filter', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/groups/by-consent?platformId=1') .get('/api/admin/groups/by-consent?platformId=1')
.set('Authorization', `Bearer ${validToken}`)
.expect(200); .expect(200);
expect(response.body).toHaveProperty('groups'); expect(response.body).toHaveProperty('groups');
@ -87,9 +86,8 @@ describe('Consent Management API', () => {
}); });
it('should accept consent filter', async () => { it('should accept consent filter', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/groups/by-consent?displayInWorkshop=true') .get('/api/admin/groups/by-consent?displayInWorkshop=true')
.set('Authorization', `Bearer ${validToken}`)
.expect(200); .expect(200);
expect(response.body).toHaveProperty('groups'); expect(response.body).toHaveProperty('groups');
@ -105,9 +103,8 @@ describe('Consent Management API', () => {
}); });
it('should return CSV format with auth and format parameter', async () => { 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') .get('/api/admin/consents/export?format=csv')
.set('Authorization', `Bearer ${validToken}`)
.expect(200); .expect(200);
expect(response.headers['content-type']).toMatch(/text\/csv/); expect(response.headers['content-type']).toMatch(/text\/csv/);
@ -115,9 +112,8 @@ describe('Consent Management API', () => {
}); });
it('should include CSV header', async () => { it('should include CSV header', async () => {
const response = await getRequest() const response = await adminSession.agent
.get('/api/admin/consents/export?format=csv') .get('/api/admin/consents/export?format=csv');
.set('Authorization', `Bearer ${validToken}`);
expect(response.text).toContain('group_id'); expect(response.text).toContain('group_id');
}); });

4
backend/tests/env.js Normal file
View 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';

View File

@ -11,7 +11,7 @@ module.exports = async () => {
// Set test environment variables // Set test environment variables
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.PORT = 5001; 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 { try {
// Create and initialize server // Create and initialize server

View File

@ -3,6 +3,11 @@
* Initialize server singleton here * 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'); const Server = require('../src/server');
// Singleton pattern - initialize only once // Singleton pattern - initialize only once
@ -13,10 +18,6 @@ async function initializeTestServer() {
if (!app) { if (!app) {
console.log('🔧 Initializing test server (one-time)...'); 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); serverInstance = new Server(5001);
app = await serverInstance.initializeApp(); app = await serverInstance.initializeApp();

View File

@ -4,17 +4,27 @@ const request = require('supertest');
* Get supertest request instance * Get supertest request instance
* Uses globally initialized server from globalSetup.js * Uses globally initialized server from globalSetup.js
*/ */
function getRequest() { let cachedAgent = null;
const app = global.__TEST_APP__;
function getApp() {
const app = global.__TEST_APP__;
if (!app) { if (!app) {
throw new Error( throw new Error(
'Test server not initialized. ' + 'Test server not initialized. This should be handled by globalSetup.js automatically.'
'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 = { module.exports = {
setupTestServer, setupTestServer,
teardownTestServer, teardownTestServer,
getRequest getRequest,
getAgent
}; };

View File

@ -1,81 +1,148 @@
const { requireAdminAuth } = require('../../src/middlewares/auth'); 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; let req, res, next;
beforeEach(() => { beforeEach(() => {
req = { headers: {} }; req = { session: null };
res = { res = {
status: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(),
json: jest.fn() json: jest.fn(),
locals: {}
}; };
next = jest.fn(); 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); requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith( expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
error: 'Zugriff verweigert', error: 'Zugriff verweigert',
message: 'Authorization header fehlt' reason: 'SESSION_REQUIRED'
}) })
); );
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
test('should reject invalid Bearer format', () => { test('should reject when session user is missing', () => {
req.headers.authorization = 'Invalid token'; req.session = {};
requireAdminAuth(req, res, next); requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith( expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ reason: 'SESSION_REQUIRED' })
message: expect.stringContaining('Ungültiges Authorization Format')
})
); );
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
test('should reject wrong token', () => { test('should reject non-admin roles', () => {
req.headers.authorization = 'Bearer wrong-token'; req.session = { user: { id: 1, role: 'viewer' } };
requireAdminAuth(req, res, next); requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith( expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ reason: 'SESSION_REQUIRED' })
message: 'Ungültiger Admin-Token'
})
); );
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
}); });
test('should allow valid token', () => { test('should pass through for admin sessions and expose user on locals', () => {
req.headers.authorization = 'Bearer test-key-123'; const adminUser = { id: 1, role: 'admin', username: 'testadmin' };
req.session = { user: adminUser };
requireAdminAuth(req, res, next); requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled(); expect(res.locals.adminUser).toEqual(adminUser);
}); });
});
test('should handle missing ADMIN_API_KEY', () => {
delete process.env.ADMIN_API_KEY; describe('AdminAuthService', () => {
req.headers.authorization = 'Bearer any-token'; beforeEach(async () => {
await dbManager.run('DELETE FROM admin_users');
requireAdminAuth(req, res, next); });
expect(res.status).toHaveBeenCalledWith(500); afterEach(async () => {
expect(res.json).toHaveBeenCalledWith( await dbManager.run('DELETE FROM admin_users');
expect.objectContaining({ });
error: 'Server-Konfigurationsfehler'
}) test('needsInitialSetup reflects admin count', async () => {
); await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(true);
expect(next).not.toHaveBeenCalled();
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
});
}); });
}); });

View 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();
});
});
});

View 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
});
});
});
});

View 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
View File

@ -5,7 +5,7 @@
set -euo pipefail set -euo pipefail
echo "🚀 Starting Project Image Uploader - Development Environment" echo "Starting Project Image Uploader - Development Environment"
echo " Frontend: http://localhost:3000" echo " Frontend: http://localhost:3000"
echo " Backend: http://localhost:5001" echo " Backend: http://localhost:5001"
echo "" echo ""
@ -18,23 +18,23 @@ if docker compose ps | grep -q "image-uploader-frontend.*Up"; then
fi fi
# Start development environment # Start development environment
echo "📦 Starting development containers..." echo "Starting development containers..."
docker compose -f docker/dev/docker-compose.yml up -d docker compose -f docker/dev/docker-compose.yml up -d
echo "" echo ""
echo "Development environment started!" echo "Development environment started!"
echo "" echo ""
echo "📊 Container Status:" echo "Container Status:"
docker compose -f docker/dev/docker-compose.yml ps docker compose -f docker/dev/docker-compose.yml ps
echo "" echo ""
echo "🔗 Access URLs:" echo "Access URLs:"
echo " 📱 Frontend (Development): http://localhost:3000" echo " Frontend (Development): http://localhost:3000"
echo " 🔧 Backend API (Development): http://localhost:5001" echo " Backend API (Development): http://localhost:5001"
echo "" echo ""
echo "📝 Useful Commands:" echo "Useful Commands:"
echo " 📋 Show logs: docker compose -f docker/dev/docker-compose.yml logs -f" 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 " Stop: docker compose -f docker/dev/docker-compose.yml down"
echo " 🔄 Restart: docker compose -f docker/dev/docker-compose.yml restart" 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 " Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache"
echo "" echo ""

View File

@ -15,9 +15,10 @@ services:
volumes: volumes:
- ../../frontend:/app:cached - ../../frontend:/app:cached
- dev_frontend_node_modules:/app/node_modules - dev_frontend_node_modules:/app/node_modules
- ./frontend/config/.env:/app/.env:ro
environment: environment:
- CHOKIDAR_USEPOLLING=true - CHOKIDAR_USEPOLLING=true
- API_URL=http://backend-dev:5000 - API_URL=http://localhost:5001
- CLIENT_URL=http://localhost:3000 - CLIENT_URL=http://localhost:3000
depends_on: depends_on:
- backend-dev - backend-dev
@ -36,6 +37,7 @@ services:
volumes: volumes:
- ../../backend:/usr/src/app:cached - ../../backend:/usr/src/app:cached
- dev_backend_node_modules:/usr/src/app/node_modules - dev_backend_node_modules:/usr/src/app/node_modules
- ./backend/config/.env:/usr/src/app/.env:ro
environment: environment:
- NODE_ENV=development - NODE_ENV=development
networks: networks:

View File

@ -23,9 +23,6 @@ RUN chmod +x ./env.sh
# Copy nginx configuration for development # Copy nginx configuration for development
COPY docker/dev/frontend/nginx.conf /etc/nginx/conf.d/default.conf 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 # 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 # node_modules are created with the correct owner and we avoid an expensive
# recursive chown later. # recursive chown later.

View File

@ -28,11 +28,8 @@ server {
# Frontend Routes (React Dev Server) # Frontend Routes (React Dev Server)
# ======================================== # ========================================
# Protected route - Moderation (HTTP Basic Auth) # Moderation route proxy (session-protected in app layer)
location /moderation { location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@ -16,7 +16,6 @@ services:
environment: environment:
- API_URL=http://backend:5000 - API_URL=http://backend:5000
- CLIENT_URL=http://localhost - CLIENT_URL=http://localhost
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
networks: networks:
- npm-nw - npm-nw
@ -36,7 +35,8 @@ services:
- prod-internal - prod-internal
environment: environment:
- NODE_ENV=production - 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: networks:
npm-nw: npm-nw:

View File

@ -14,9 +14,6 @@ FROM nginx:stable-alpine
RUN rm -rf /etc/nginx/conf.d RUN rm -rf /etc/nginx/conf.d
COPY docker/prod/frontend/nginx.conf /etc/nginx/nginx.conf 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 # Static build
COPY --from=build /app/build /usr/share/nginx/html COPY --from=build /app/build /usr/share/nginx/html

View File

@ -51,19 +51,6 @@ http {
client_max_body_size 100M; 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) # API - Groups API routes (NO PASSWORD PROTECTION)
location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ { location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ {
proxy_pass http://image-uploader-backend:5000; proxy_pass http://image-uploader-backend:5000;
@ -92,11 +79,8 @@ http {
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always; add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
} }
# Protected routes - Moderation (password protected) # Moderation UI (session-protected within the app)
location /moderation { location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@ -1,9 +1,6 @@
# Frontend Environment Variables # Frontend Environment Variables
# Admin API Authentication Token # Currently no frontend-specific secrets are required. Add overrides (e.g. public API URLs)
# Generate with: openssl rand -hex 32 # via `REACT_APP_*` variables only if they are safe to expose to browsers.
# Must match ADMIN_API_KEY in backend/.env # Example:
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here # REACT_APP_PUBLIC_API_BASE=https://example.com
# API Base URL (optional, defaults to same domain)
# REACT_APP_API_URL=http://localhost:3001

View File

@ -103,75 +103,73 @@ fetch('/api/admin/groups/123/approve') // Admin (+ Bearer Token!)
fetch('/api/admin/groups/123') // 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 Die Admin-API verwendet jetzt serverseitige Sessions mit CSRF-Schutz. Statt Tokens in `.env` zu hinterlegen, erfolgt die Authentifizierung über Login-Endpunkte:
# frontend/.env oder frontend/.env.local
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
```
**Token generieren:** 1. **Setup-Status abfragen** `GET /auth/setup/status``{ needsSetup, hasSession }`
```bash 2. **Ersten Admin anlegen** `POST /auth/setup/initial-admin` (nur einmal nötig)
# Linux/Mac: 3. **Login** `POST /auth/login` mit `{ username, password }`
openssl rand -hex 32 4. **CSRF Token holen** `GET /auth/csrf-token` (liefert `csrfToken` und setzt HttpOnly Session-Cookie)
# Node.js: Alle nachfolgenden Admin-Requests senden automatisch das Session-Cookie (`credentials: 'include'`) und den `X-CSRF-Token` Header.
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
**⚠️ Wichtig**: Gleiches Token in Backend `.env` als `ADMIN_API_KEY` eintragen!
### 3. API-Aufrufe für Admin-Endpoints anpassen ### 3. API-Aufrufe für Admin-Endpoints anpassen
#### Vorher (ohne Auth): #### Vorher (ohne Session):
```javascript ```javascript
const response = await fetch('/api/admin/groups'); const response = await fetch('/api/admin/groups');
``` ```
#### Nachher (mit Bearer Token): #### Nachher (mit Session + CSRF):
```javascript ```javascript
const response = await fetch('/api/admin/groups', { const response = await fetch('/api/admin/groups', {
method: 'GET',
credentials: 'include',
headers: { headers: {
'Authorization': `Bearer ${process.env.REACT_APP_ADMIN_API_KEY}`, 'X-CSRF-Token': csrfToken, // nur bei mutierenden Requests zwingend nötig
'Content-Type': 'application/json'
} }
}); });
``` ```
### 3. Zentrale API-Helper-Funktion erstellen ### 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 ```javascript
// src/services/adminApiService.js const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const ADMIN_API_KEY = process.env.REACT_APP_ADMIN_API_KEY; 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 = {}) => { export const adminFetch = async (url, options = {}) => {
const defaultHeaders = { const method = (options.method || 'GET').toUpperCase();
'Authorization': `Bearer ${ADMIN_API_KEY}`, const headers = new Headers(options.headers || {});
'Content-Type': 'application/json'
}; if (!SAFE_METHODS.has(method)) {
headers.set('X-CSRF-Token', await ensureCsrfToken());
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers: { method,
...defaultHeaders, credentials: 'include',
...options.headers headers
}
}); });
if (response.status === 403) { if (!response.ok) {
throw new Error('Authentication failed - Invalid or missing admin token'); throw await parseError(response);
} }
return 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 ### 4. Error Handling erweitern
@ -179,22 +177,20 @@ const data = await response.json();
```javascript ```javascript
try { try {
const response = await adminFetch('/api/admin/groups'); 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(); const data = await response.json();
// ... // ...
} catch (error) { } catch (error) {
console.error('Admin API error:', 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` - `Components/Pages/ModerationGroupsPage.js`
- ❌ `/groups/${groupId}/approve` → ✅ `/api/admin/groups/${groupId}/approve` - ❌ `/groups/${groupId}/approve` → ✅ `/api/admin/groups/${groupId}/approve`
- ❌ `/groups/${groupId}` (DELETE) → ✅ `/api/admin/groups/${groupId}` - ❌ `/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` - `Components/Pages/ModerationGroupImagesPage.js`
- ❌ `/moderation/groups/${groupId}` → ✅ `/api/admin/groups/${groupId}` - ❌ `/moderation/groups/${groupId}` → ✅ `/api/admin/groups/${groupId}`
@ -233,12 +230,12 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
- `Components/Pages/PublicGroupImagesPage.js` - `Components/Pages/PublicGroupImagesPage.js`
- ❌ `/groups/${groupId}` → ✅ `/api/groups/${groupId}` - ❌ `/groups/${groupId}` → ✅ `/api/groups/${groupId}`
### Admin-Endpoints (benötigen Bearer Token): ### Admin-Endpoints (Session + CSRF erforderlich):
- `Components/Pages/ModerationGroupsPage.js` - Alle Admin-Calls - `Components/Pages/ModerationGroupsPage.js` - Alle Moderations-Calls
- `Components/Pages/ModerationGroupImagesPage.js` - Gruppe laden + Bilder löschen - `Components/Pages/ModerationGroupImagesPage.js` - Gruppe laden + Bilder löschen
- `Components/ComponentUtils/DeletionLogSection.js` - Deletion Log - `Components/ComponentUtils/DeletionLogSection.js` - Deletion Log
- `Components/ComponentUtils/ConsentManager.js` - Consent Export (wenn Admin) - `Components/ComponentUtils/ConsentManager.js` - Consent-Export (Admin)
- `services/reorderService.js` - Admin-Reorder (wenn vorhanden) - `services/reorderService.js` - Admin-Reorder (falls im Einsatz)
### Public/Management Endpoints (nur Pfad prüfen): ### Public/Management Endpoints (nur Pfad prüfen):
- `Utils/batchUpload.js` - Bereits korrekt (`/api/...`) - `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 - [ ] Admin-Routen auf `/api/admin/*` geändert
- [ ] Management-Routen auf `/api/manage/*` geprüft (sollten schon korrekt sein) - [ ] Management-Routen auf `/api/manage/*` geprüft (sollten schon korrekt sein)
### Phase 2: Admin Authentication ### Phase 2: Admin Authentication (Session)
- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefügt - [ ] `AdminSessionProvider` wrappt die App
- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert - [ ] `AdminSessionGate` schützt alle Moderationsseiten
- [ ] Zentrale `adminFetch` Funktion erstellt - [ ] `adminApi.js` nutzt `credentials: 'include'` + `X-CSRF-Token`
- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefügt - [ ] Login- und Initial-Setup-Formulare eingebunden
- [ ] Authorization Header zu `/api/system/*` Calls hinzugefügt (falls vorhanden) - [ ] Fehlerbehandlung für `401/403 (SESSION_REQUIRED/CSRF_INVALID)` ergänzt
- [ ] 403 Error Handling implementiert
### Phase 3: Testing & Deployment ### Phase 3: Testing & Deployment
- [ ] Frontend lokal getestet (alle Routen) - [ ] Frontend lokal getestet (alle Routen)
@ -276,29 +272,20 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
### Lokales Testing ### Lokales Testing
1. Backend mit Admin-Key starten: 1. Backend starten (`npm run dev`) stellt Session- & Auth-Routen bereit.
```bash 2. Frontend starten (`npm start`).
cd backend 3. `/moderation` öffnen:
echo "ADMIN_API_KEY=test-key-12345" >> .env - **Falls kein Admin existiert** → Setup-Formular ausfüllen.
npm run dev - Danach mit frisch erstellten Credentials anmelden.
``` 4. Moderationsfunktionen (Approve/Delete/Reorder/Consent-Export) durchspielen.
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
### Test-Fälle ### Test-Fälle
- ✅ Admin-Funktionen funktionieren mit gültigem Token - ✅ Moderation funktioniert mit aktiver Session
- ✅ 403 Error bei fehlendem/falschem Token - ✅ Login/Logout ändert sofort den Zugriff auf Seiten
- ✅ Consent-Export funktioniert - ✅ CSRF-geschützte Aktionen schlagen fehl, wenn Token manipuliert wird
- ✅ Gruppen löschen funktioniert - ✅ Consent-Export & Reorder funktionieren weiterhin
- ✅ Bilder neu anordnen funktioniert - ✅ Ö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` - **API Route-Übersicht**: `backend/src/routes/README.md`
- **Route-Konfiguration**: `backend/src/routes/routeMappings.js` - **Route-Konfiguration**: `backend/src/routes/routeMappings.js`
- **OpenAPI Spec**: `backend/docs/openapi.json` - **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 ## 🆘 Troubleshooting
### Problem: "403 Forbidden" Fehler ### Problem: "Session Required" / 403 Fehler
**Ursachen:** **Ursachen:**
1. `REACT_APP_ADMIN_API_KEY` nicht gesetzt 1. Session abgelaufen (Inaktivität, Browser geschlossen)
2. Token falsch konfiguriert (Frontend ≠ Backend) 2. Cookies blockiert (Third-Party/SameSite Einstellungen)
3. Token enthält Leerzeichen/Zeilenumbrüche
**Lösung:** **Lösung:**
```bash - Seite neu laden → Login-Formular erscheint
# Frontend .env prüfen: - Browser-Einstellungen prüfen: Cookies für Host erlauben
cat frontend/.env | grep ADMIN_API_KEY
# Backend .env prüfen: ### Problem: "CSRF invalid"
cat backend/.env | grep ADMIN_API_KEY
# Beide müssen identisch sein! **Ursachen:**
``` - CSRF-Token nicht gesetzt oder veraltet
### Problem: "ADMIN_API_KEY not configured" (500 Error)
**Ursache:** Backend hat `ADMIN_API_KEY` nicht in `.env`
**Lösung:** **Lösung:**
```bash - `AdminSessionGate` neu laden → holt automatisch neues Token
cd backend - Sicherstellen, dass `adminApi` bei mutierenden Calls `X-CSRF-Token` setzt
echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env
```
### Problem: Token wird nicht gesendet ### Problem: Setup-Formular erscheint nicht
**Prüfen in Browser DevTools:** **Ursachen:**
1. Network Tab öffnen - Bereits ein Admin vorhanden
2. Admin-API-Request auswählen
3. "Headers" Tab prüfen
4. Sollte enthalten: `Authorization: Bearer <token>`
### 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`: **Checks:**
```javascript 1. Backend-Logs prüfen (Rate-Limits? falsches Passwort?)
allowedHeaders: ['Content-Type', 'Authorization'] 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 ### Production Checklist
- [ ] Sicheren Admin-Token generiert (min. 32 Bytes hex) - [ ] Sicheres `ADMIN_SESSION_SECRET` (>= 32 random bytes) gesetzt
- [ ] Token in Backend `.env` als `ADMIN_API_KEY` - [ ] HTTPS aktiviert (Cookies: `Secure`, `SameSite=Strict`)
- [ ] Token in Frontend Build-Environment als `REACT_APP_ADMIN_API_KEY` - [ ] Session-DB Pfad (`ADMIN_SESSION_DIR`/`ADMIN_SESSION_DB`) persistent gemacht
- [ ] Token NICHT in Git committed (in `.gitignore`) - [ ] Admin-Benutzer erstellt und dokumentiert (kein Secret im Frontend)
- [ ] HTTPS verwendet (Token über unverschlüsselte HTTP ist unsicher) - [ ] Monitoring/Alerting für fehlgeschlagene Logins eingerichtet
- [ ] Token-Rotation-Prozess dokumentiert
- [ ] Backup des Tokens an sicherem Ort gespeichert
### Docker Deployment ### Docker Deployment
@ -380,16 +355,17 @@ allowedHeaders: ['Content-Type', 'Authorization']
services: services:
backend: backend:
environment: environment:
- ADMIN_API_KEY=${ADMIN_API_KEY} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_DIR=/data/sessions
# optional weitere Backend-ENV Variablen
frontend: frontend:
environment: environment:
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY} - PUBLIC_URL=${PUBLIC_URL:-/}
``` ```
```bash ```bash
# .env (nicht in Git!) # .env (nicht in Git!)
ADMIN_API_KEY=your-production-token-here ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
``` ```
--- ---

View File

@ -33,7 +33,7 @@
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"proxy": "http://localhost:5001", "proxy": "http://backend-dev:5000",
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app",

View File

@ -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 { 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 { background:#6c757d; color:white; }
.btn-secondary:hover { background:#5a6268; } .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 { background:#28a745; color:white; }
.btn-success:hover { background:#218838; } .btn-success:hover { background:#218838; }
.btn-warning { background:#ffc107; color:#212529; } .btn-warning { background:#ffc107; color:#212529; }
@ -60,6 +62,7 @@
.btn-danger { background:#dc3545; color:white; } .btn-danger { background:#dc3545; color:white; }
.btn-danger:hover { background:#c82333; } .btn-danger:hover { background:#c82333; }
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; } .btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
.btn:disabled { opacity:0.65; cursor:not-allowed; }
/* Modal */ /* 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; } .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; } .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; } .empty-state { text-align:center; padding:60px 20px; }
.loading-container { 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; }

View File

@ -1,5 +1,6 @@
import './App.css'; import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
// Pages // Pages
import MultiUploadPage from './Components/Pages/MultiUploadPage'; import MultiUploadPage from './Components/Pages/MultiUploadPage';
@ -13,8 +14,9 @@ import FZF from './Components/Pages/404Page.js'
function App() { function App() {
return ( return (
<Router> <AdminSessionProvider>
<Routes> <Router>
<Routes>
<Route path="/" exact element={<MultiUploadPage />} /> <Route path="/" exact element={<MultiUploadPage />} />
<Route path="/slideshow" element={<SlideshowPage />} /> <Route path="/slideshow" element={<SlideshowPage />} />
<Route path="/groups/:groupId" element={<PublicGroupImagesPage />} /> <Route path="/groups/:groupId" element={<PublicGroupImagesPage />} />
@ -23,8 +25,9 @@ function App() {
<Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} /> <Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} /> <Route path="/manage/:token" element={<ManagementPortalPage />} />
<Route path="*" element={<FZF />} /> <Route path="*" element={<FZF />} />
</Routes> </Routes>
</Router> </Router>
</AdminSessionProvider>
); );
} }

View 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;

View 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;

View 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;

View 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;

View File

@ -10,8 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
// Services // Services
import { adminGet } from '../../../services/adminApi'; import { getActiveSocialMediaPlatforms } from '../../../services/socialMediaApi';
import { handleAdminError } from '../../../services/adminErrorHandler';
import InfoIcon from '@mui/icons-material/Info'; import InfoIcon from '@mui/icons-material/Info';
import FacebookIcon from '@mui/icons-material/Facebook'; import FacebookIcon from '@mui/icons-material/Facebook';
import InstagramIcon from '@mui/icons-material/Instagram'; import InstagramIcon from '@mui/icons-material/Instagram';
@ -56,11 +55,11 @@ function ConsentCheckboxes({
const fetchPlatforms = async () => { const fetchPlatforms = async () => {
try { try {
const data = await adminGet('/api/admin/social-media/platforms'); const data = await getActiveSocialMediaPlatforms();
setPlatforms(data); setPlatforms(data);
setError(null); setError(null);
} catch (error) { } catch (error) {
await handleAdminError(error, 'Plattformen laden'); console.error('Fehler beim Laden der Plattformen:', error);
setError('Plattformen konnten nicht geladen werden'); setError('Plattformen konnten nicht geladen werden');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -5,6 +5,8 @@ import { Container, Box } from '@mui/material';
// Services // Services
import { adminGet } from '../../services/adminApi'; import { adminGet } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler'; import { handleAdminError } from '../../services/adminErrorHandler';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
// Components // Components
import Navbar from '../ComponentUtils/Headers/Navbar'; import Navbar from '../ComponentUtils/Headers/Navbar';
@ -26,8 +28,12 @@ const ModerationGroupImagesPage = () => {
const [group, setGroup] = useState(null); const [group, setGroup] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { isAuthenticated } = useAdminSession();
const loadGroup = useCallback(async () => { const loadGroup = useCallback(async () => {
if (!isAuthenticated) {
return;
}
try { try {
setLoading(true); setLoading(true);
const data = await adminGet(`/api/admin/groups/${groupId}`); const data = await adminGet(`/api/admin/groups/${groupId}`);
@ -57,51 +63,61 @@ const ModerationGroupImagesPage = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [groupId]); }, [groupId, isAuthenticated]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) {
return;
}
loadGroup(); loadGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, loadGroup]);
}, [groupId]);
if (loading) return <Loading />; const renderContent = () => {
if (error) return <div className="moderation-error">{error}</div>; if (loading) return <Loading />;
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>; if (error) return <div className="moderation-error">{error}</div>;
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>;
return (
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
{/* Image Descriptions Manager */}
<ImageDescriptionManager
images={group.images}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{/* Group Metadata Editor */}
<GroupMetadataEditor
initialMetadata={group.metadata}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{/* Back Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<button
className="btn btn-secondary"
onClick={() => navigate('/moderation')}
>
Zurück zur Übersicht
</button>
</Box>
</Container>
<div className="footerContainer"><Footer /></div>
</div>
);
};
return ( return (
<div className="allContainer"> <AdminSessionGate>
<Navbar /> {renderContent()}
</AdminSessionGate>
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
{/* Image Descriptions Manager */}
<ImageDescriptionManager
images={group.images}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{/* Group Metadata Editor */}
<GroupMetadataEditor
initialMetadata={group.metadata}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{/* Back Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<button
className="btn btn-secondary"
onClick={() => navigate('/moderation')}
>
Zurück zur Übersicht
</button>
</Box>
</Container>
<div className="footerContainer"><Footer /></div>
</div>
); );
}; };

View File

@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom'; 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 FilterListIcon from '@mui/icons-material/FilterList';
import Swal from 'sweetalert2/dist/sweetalert2.js'; import Swal from 'sweetalert2/dist/sweetalert2.js';
// Services // Services
import { adminGet, adminRequest, adminDownload } from '../../services/adminApi'; import { adminGet, adminRequest, adminDownload } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler'; import { handleAdminError } from '../../services/adminErrorHandler';
import { getActiveSocialMediaPlatforms } from '../../services/socialMediaApi';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
// Components // Components
import Navbar from '../ComponentUtils/Headers/Navbar'; import Navbar from '../ComponentUtils/Headers/Navbar';
@ -30,19 +33,27 @@ const ModerationGroupsPage = () => {
}); });
const [platforms, setPlatforms] = useState([]); const [platforms, setPlatforms] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
const { isAuthenticated, logout, user } = useAdminSession();
const [logoutPending, setLogoutPending] = useState(false);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) {
return;
}
loadModerationGroups(); loadModerationGroups();
loadPlatforms(); loadPlatforms();
}, []); }, [isAuthenticated]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) {
return;
}
loadModerationGroups(); loadModerationGroups();
}, [consentFilters]); }, [consentFilters, isAuthenticated]);
const loadPlatforms = async () => { const loadPlatforms = async () => {
try { try {
const data = await adminGet('/api/admin/social-media/platforms'); const data = await getActiveSocialMediaPlatforms();
setPlatforms(data); setPlatforms(data);
} catch (error) { } catch (error) {
await handleAdminError(error, 'Plattformen laden'); await handleAdminError(error, 'Plattformen laden');
@ -146,7 +157,18 @@ const ModerationGroupsPage = () => {
}; };
const deleteGroup = async (groupId) => { 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; return;
} }
@ -158,6 +180,14 @@ const ModerationGroupsPage = () => {
setSelectedGroup(null); setSelectedGroup(null);
setShowImages(false); setShowImages(false);
} }
await Swal.fire({
icon: 'success',
title: 'Gruppe gelöscht',
text: 'Die Gruppe wurde vollständig entfernt.',
timer: 1800,
showConfirmButton: false
});
} catch (error) { } catch (error) {
await handleAdminError(error, 'Gruppe löschen'); await handleAdminError(error, 'Gruppe löschen');
} }
@ -193,161 +223,219 @@ const ModerationGroupsPage = () => {
} }
}; };
if (loading) { const handleLogoutClick = async () => {
return <div className="moderation-loading">Lade Gruppen...</div>; 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);
}
};
if (error) { const renderContent = () => {
return <div className="moderation-error">{error}</div>; if (loading) {
} return <div className="moderation-loading">Lade Gruppen...</div>;
}
const pendingGroups = groups.filter(g => !g.approved); if (error) {
const approvedGroups = groups.filter(g => g.approved); return <div className="moderation-error">{error}</div>;
}
const pendingGroups = groups.filter(g => !g.approved);
const approvedGroups = groups.filter(g => g.approved);
return (
<div className="allContainer">
<Navbar />
<Helmet>
<title>Moderation - Interne Verwaltung</title>
<meta name="robots" content="noindex, nofollow, nosnippet, noarchive" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
</Helmet>
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
<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">
<span className="stat-number">{pendingGroups.length}</span>
<span className="stat-label">Wartend</span>
</div>
<div className="stat-item">
<span className="stat-number">{approvedGroups.length}</span>
<span className="stat-label">Freigegeben</span>
</div>
<div className="stat-item">
<span className="stat-number">{groups.length}</span>
<span className="stat-label">Gesamt</span>
</div>
</div>
{/* Filter und Export Controls */}
<Box sx={{
display: 'flex',
gap: 2,
mb: 3,
alignItems: 'center',
flexWrap: 'wrap'
}}>
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
Consent-Filter
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.workshop}
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
size="small"
/>
}
label="Werkstatt"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.facebook}
onChange={(e) => setConsentFilters({...consentFilters, facebook: e.target.checked})}
size="small"
/>
}
label="Facebook"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.instagram}
onChange={(e) => setConsentFilters({...consentFilters, instagram: e.target.checked})}
size="small"
/>
}
label="Instagram"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.tiktok}
onChange={(e) => setConsentFilters({...consentFilters, tiktok: e.target.checked})}
size="small"
/>
}
label="TikTok"
/>
</FormGroup>
</FormControl>
<button
className="btn btn-success"
onClick={exportConsentData}
style={{
fontSize: '14px',
padding: '10px 20px'
}}
>
📥 Consent-Daten exportieren
</button>
</Box>
{/* Wartende Gruppen */}
<section className="moderation-section">
<ImageGallery
items={pendingGroups}
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={true}
mode="moderation"
emptyMessage="Keine wartenden Gruppen"
/>
</section>
{/* Freigegebene Gruppen */}
<section className="moderation-section">
<ImageGallery
items={approvedGroups}
title={`✅ Freigegebene Gruppen (${approvedGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={false}
mode="moderation"
emptyMessage="Keine freigegebenen Gruppen"
/>
</section>
{/* Lösch-Historie */}
<section className="moderation-section">
<DeletionLogSection />
</section>
{/* Bilder-Modal */}
{showImages && selectedGroup && (
<ImageModal
group={selectedGroup}
onClose={() => {
setShowImages(false);
setSelectedGroup(null);
}}
onDeleteImage={deleteImage}
/>
)}
</Container>
<div className="footerContainer"><Footer /></div>
</div>
);
};
return ( return (
<div className="allContainer"> <AdminSessionGate>
<Navbar /> {renderContent()}
<Helmet> </AdminSessionGate>
<title>Moderation - Interne Verwaltung</title>
<meta name="robots" content="noindex, nofollow, nosnippet, noarchive" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
</Helmet>
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
<h1>Moderation</h1>
<div className="moderation-stats">
<div className="stat-item">
<span className="stat-number">{pendingGroups.length}</span>
<span className="stat-label">Wartend</span>
</div>
<div className="stat-item">
<span className="stat-number">{approvedGroups.length}</span>
<span className="stat-label">Freigegeben</span>
</div>
<div className="stat-item">
<span className="stat-number">{groups.length}</span>
<span className="stat-label">Gesamt</span>
</div>
</div>
{/* Filter und Export Controls */}
<Box sx={{
display: 'flex',
gap: 2,
mb: 3,
alignItems: 'center',
flexWrap: 'wrap'
}}>
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
Consent-Filter
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.workshop}
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
size="small"
/>
}
label="Werkstatt"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.facebook}
onChange={(e) => setConsentFilters({...consentFilters, facebook: e.target.checked})}
size="small"
/>
}
label="Facebook"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.instagram}
onChange={(e) => setConsentFilters({...consentFilters, instagram: e.target.checked})}
size="small"
/>
}
label="Instagram"
/>
<FormControlLabel
control={
<Checkbox
checked={consentFilters.tiktok}
onChange={(e) => setConsentFilters({...consentFilters, tiktok: e.target.checked})}
size="small"
/>
}
label="TikTok"
/>
</FormGroup>
</FormControl>
<button
className="btn btn-success"
onClick={exportConsentData}
style={{
fontSize: '14px',
padding: '10px 20px'
}}
>
📥 Consent-Daten exportieren
</button>
</Box>
{/* Wartende Gruppen */}
<section className="moderation-section">
<ImageGallery
items={pendingGroups}
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={true}
mode="moderation"
emptyMessage="Keine wartenden Gruppen"
/>
</section>
{/* Freigegebene Gruppen */}
<section className="moderation-section">
<ImageGallery
items={approvedGroups}
title={`✅ Freigegebene Gruppen (${approvedGroups.length})`}
onApprove={approveGroup}
onViewImages={viewGroupImages}
onDelete={deleteGroup}
isPending={false}
mode="moderation"
emptyMessage="Keine freigegebenen Gruppen"
/>
</section>
{/* Lösch-Historie */}
<section className="moderation-section">
<DeletionLogSection />
</section>
{/* Bilder-Modal */}
{showImages && selectedGroup && (
<ImageModal
group={selectedGroup}
onClose={() => {
setShowImages(false);
setSelectedGroup(null);
}}
onDeleteImage={deleteImage}
/>
)}
</Container>
<div className="footerContainer"><Footer /></div>
</div>
); );
}; };

View 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;
};

View File

@ -1,62 +1,108 @@
/** const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
* Admin API Helper mit Bearer Token Authentication const CSRF_STORAGE_KEY = 'piu.admin.csrfToken';
*
* Verwendet für alle /api/admin/* und /api/system/* Endpoints
*/
/** let csrfToken = null;
* Führt einen fetch-Request mit Admin-Bearer-Token aus if (typeof window !== 'undefined' && window.sessionStorage) {
* @param {string} url - Die URL (mit /api/admin/* oder /api/system/* Prefix) csrfToken = window.sessionStorage.getItem(CSRF_STORAGE_KEY);
* @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;
if (!token) { const persistCsrfToken = (token) => {
console.error('REACT_APP_ADMIN_API_KEY not configured!'); csrfToken = token;
throw new Error('Admin API Token not configured'); 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
});
}; };
/** const parseErrorResponse = async (response) => {
* Hilfsfunktion für GET-Requests mit automatischer JSON-Parsing und Error-Handling let payload = null;
* @param {string} url try {
* @returns {Promise<any>} payload = await response.json();
*/ } catch (error) {
export const adminGet = async (url) => { payload = null;
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 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) {
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(); 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) => { export const adminRequest = async (url, method, body = null) => {
const options = { const options = {
method, method,
@ -70,37 +116,81 @@ export const adminRequest = async (url, method, body = null) => {
} }
const response = await adminFetch(url, options); 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; return response;
}; };
/**
* Hilfsfunktion für Blob/File Downloads (CSV, PDF, etc.)
* @param {string} url
* @returns {Promise<Blob>}
*/
export const adminDownload = async (url) => { export const adminDownload = async (url) => {
const response = await adminFetch(url); const response = await adminFetch(url, { method: 'GET', requireCsrf: false });
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.blob(); 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();
};

View File

@ -13,30 +13,44 @@ import Swal from 'sweetalert2/dist/sweetalert2.js';
export const handleAdminError = async (error, context = 'Operation') => { export const handleAdminError = async (error, context = 'Operation') => {
console.error(`Admin API Error [${context}]:`, error); console.error(`Admin API Error [${context}]:`, error);
// 403 Unauthorized - Admin Token fehlt oder ungültig const status = error?.status;
if (error.message.includes('Unauthorized') || error.message.includes('403')) { const reason = error?.reason || error?.code || error?.payload?.reason;
// Session missing or expired
if (status === 401 || reason === 'SESSION_REQUIRED') {
await Swal.fire({ await Swal.fire({
icon: 'error', icon: 'warning',
title: 'Authentifizierung fehlgeschlagen', title: 'Anmeldung erforderlich',
html: ` text: 'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
<p><strong>Admin-Token fehlt oder ist ungültig.</strong></p> confirmButtonText: 'Zum Login'
<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'
}); });
return; return;
} }
// 429 Rate Limit // CSRF token invalid or missing
if (error.message.includes('Too many requests') || error.message.includes('429')) { 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({ await Swal.fire({
icon: 'warning', icon: 'warning',
title: 'Zu viele Anfragen', title: 'Zu viele Anfragen',
@ -47,33 +61,33 @@ export const handleAdminError = async (error, context = 'Operation') => {
return; return;
} }
// 404 Not Found // Not found
if (error.message.includes('404')) { if (status === 404) {
await Swal.fire({ await Swal.fire({
icon: 'error', icon: 'error',
title: 'Nicht gefunden', title: 'Nicht gefunden',
text: `Die angeforderte Ressource wurde nicht gefunden.`, text: 'Die angeforderte Ressource wurde nicht gefunden.',
confirmButtonText: 'OK' confirmButtonText: 'OK'
}); });
return; return;
} }
// 500 Server Error // Server error
if (error.message.includes('500')) { if (status && status >= 500) {
await Swal.fire({ await Swal.fire({
icon: 'error', icon: 'error',
title: 'Server-Fehler', 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' confirmButtonText: 'OK'
}); });
return; return;
} }
// Generischer Fehler // Generic fallback
await Swal.fire({ await Swal.fire({
icon: 'error', icon: 'error',
title: `Fehler: ${context}`, title: `Fehler: ${context}`,
text: error.message || 'Ein unbekannter Fehler ist aufgetreten.', text: error?.message || 'Ein unbekannter Fehler ist aufgetreten.',
confirmButtonText: 'OK' confirmButtonText: 'OK'
}); });
}; };

View 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 || [];
};

View File

@ -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. 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 - 🔍 **Rekursives Verzeichnis-Scanning** nach unterstützten Bildformaten
- 📊 **Automatische Metadaten-Extraktion** aus EXIF-Daten und Pfad-Struktur - 📊 **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 - 📈 **Progress-Tracking** und Error-Handling
- 🏗️ **Strukturierte Metadaten** (Jahr, Titel, Beschreibung, Name) - 🏗️ **Strukturierte Metadaten** (Jahr, Titel, Beschreibung, Name)
- 🔐 **Admin-Session Login** mit CSRF-Schutz (entsprechend `AUTHENTICATION.md`)
## Installation ## Installation
@ -25,10 +36,12 @@ pip install requests pillow
### Einfacher Upload ### Einfacher Upload
```bash ```bash
# Linux/macOS # 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 # 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 ### Erweiterte Optionen
@ -38,30 +51,38 @@ python batch_uploader.py ./photos \
--titel "Urlaubsbilder 2024" \ --titel "Urlaubsbilder 2024" \
--name "Max Mustermann" \ --name "Max Mustermann" \
--backend http://localhost:5000 \ --backend http://localhost:5000 \
--chunk-size 10 --user admin --password 'SehrSicher123!' \
--social-media-consents consents.json
## Windows / WSL: Pfade mit Leerzeichen und Sonderzeichen ## Windows / WSL: Pfade mit Leerzeichen und Sonderzeichen
Windows-PowerShell (empfohlen): Windows-PowerShell (empfohlen):
```powershell ```powershell
# Doppelte Anführungszeichen um den kompletten Pfad, Backslashes bleiben unverändert # 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: Windows (CMD) ohne Anführungszeichen mit Escapes oder mit Anführungszeichen:
```bat ```bat
REM Mit Backslashes escapen (CMD): 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: 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: WSL / Linux (bash) Pfad in /mnt/c/... verwenden, ohne zusätzliche Backslashes in Quotes:
```bash ```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: # 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: Hinweis:
@ -84,11 +105,44 @@ python batch_uploader.py ./images --no-recursive
| `--titel` | Standard-Titel für alle Bilder | Aus Pfad extrahiert | | `--titel` | Standard-Titel für alle Bilder | Aus Pfad extrahiert |
| `--name` | Standard-Name für alle Bilder | Leer | | `--name` | Standard-Name für alle Bilder | Leer |
| `--backend` | Backend-URL | `http://localhost:5000` | | `--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` | | `--no-recursive` | Nicht in Unterverzeichnisse | `False` |
| `--dry-run` | Nur Analyse, kein Upload | `False` | | `--dry-run` | Nur Analyse, kein Upload | `False` |
| `--verbose` | Detailliertes Logging | `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 ## Metadaten-Extraktion
### Erwartete Struktur: `Photos/Jahr/Name/Projekt/dateiname.endung` ### 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 - **Connection-Timeout**: 10s für Backend-Test, 60s für Upload
- **File-Errors**: Automatisches Skip von beschädigten Bildern - **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 - **Retry-Logic**: Verwendet Session für Connection-Reuse
## Cross-Platform Support ## Cross-Platform Support
@ -251,10 +305,8 @@ python batch_uploader.py "\\\\nas-server\\photos\\2024" --verbose
### Backend nicht erreichbar ### Backend nicht erreichbar
```bash ```bash
### Backend Status prüfen
```bash
# Prüfe Backend-Status # Prüfe Backend-Status
curl http://localhost:5000/groups curl http://localhost:5000/api/groups
# Backend starten # Backend starten
cd ../ cd ../
@ -269,9 +321,10 @@ pip install --upgrade Pillow
### Performance bei großen Batches ### Performance bei großen Batches
```bash ```bash
# Kleinere Chunk-Size verwenden # Nach Jahr oder Name aufteilen
python batch_uploader.py /photos --chunk-size 3 python batch_uploader.py /photos/2024/Familie_Schmidt \
--user admin --password 'SehrSicher123!'
# Progress verfolgen # Vorab prüfen
python batch_uploader.py /photos --verbose python batch_uploader.py /photos --dry-run --verbose
``` ```

View File

@ -9,15 +9,8 @@ mit strukturierten Metadaten an das Image-Uploader Backend.
Features: Features:
- Rekursives Verzeichnis-Scanning nach Bildern - Rekursives Verzeichnis-Scanning nach Bildern
- Metadaten-Extraktion aus Verzeichnis-/Dateinamen - Metadaten-Extraktion aus Verzeichnis-/Dateinamen
- Batch-Upload an das Backen self.logger.info(f"📊 Upload abgeschlossen: {len(project_groups)} Gruppen erstellt") - Batch-Upload an das Backend mit Session-Authentifizierung
- Fortschritts-Tracking und Error-Handling
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
- EXIF-Daten Unterstützung (optional) - EXIF-Daten Unterstützung (optional)
Usage: Usage:
@ -30,7 +23,7 @@ import json
import requests import requests
import argparse import argparse
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Tuple from typing import Any, List, Dict, Optional, Tuple
import mimetypes import mimetypes
from PIL import Image, ExifTags from PIL import Image, ExifTags
from PIL.ExifTags import TAGS from PIL.ExifTags import TAGS
@ -39,11 +32,35 @@ from datetime import datetime
import logging import logging
# Konfiguration # Konfiguration
#DEFAULT_BACKEND_URL = "https://deinprojekt.lan.hobbyhimmel.de/api" DEFAULT_BACKEND_URL = "http://localhost:5000"
DEFAULT_BACKEND_URL = "http://localhost/api"
SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'} SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB 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: class ImageMetadataExtractor:
"""Extrahiert Metadaten aus Bildern und Verzeichnissen """Extrahiert Metadaten aus Bildern und Verzeichnissen
@ -78,7 +95,7 @@ class ImageMetadataExtractor:
if not re.match(r'^(19|20)\d{2}$', jahr): if not re.match(r'^(19|20)\d{2}$', jahr):
self.logger.debug(f"Ungültiges Jahr in Pfad: {jahr}") self.logger.debug(f"Ungültiges Jahr in Pfad: {jahr}")
# Versuche Jahr aus anderen Teilen zu extrahieren # 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 { return {
'jahr': jahr, 'jahr': jahr,
@ -222,17 +239,97 @@ class ImageMetadataExtractor:
class BatchUploader: class BatchUploader:
"""Haupt-Klasse für Batch-Upload""" """Haupt-Klasse für Batch-Upload"""
def __init__(self, backend_url: str = DEFAULT_BACKEND_URL, user: str = None, password: str = None): def __init__(self, backend_url: str = DEFAULT_BACKEND_URL,
self.backend_url = backend_url.rstrip('/') 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.metadata_extractor = ImageMetadataExtractor()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Session für Connection-Reuse # Session für Connection-Reuse
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ 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]: def scan_directory(self, directory: Path, recursive: bool = True) -> List[Path]:
"""Scannt Verzeichnis nach unterstützten Bildern""" """Scannt Verzeichnis nach unterstützten Bildern"""
@ -305,6 +402,7 @@ class BatchUploader:
def upload_batch(self, images: List[Path], def upload_batch(self, images: List[Path],
default_titel: Optional[str] = None, default_titel: Optional[str] = None,
default_name: Optional[str] = None, default_name: Optional[str] = None,
consents: Optional[Dict[str, Any]] = None,
dry_run: bool = False) -> Dict: dry_run: bool = False) -> Dict:
""" """
Uploaded Bilder gruppiert nach PROJEKTEN (Jahr/Name/Projekt) Uploaded Bilder gruppiert nach PROJEKTEN (Jahr/Name/Projekt)
@ -317,6 +415,14 @@ class BatchUploader:
if not images: if not images:
return {'total': 0, 'successful': 0, 'failed': 0, 'failed_files': []} 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 # 1. Bilder nach Projekten gruppieren
project_groups = {} project_groups = {}
@ -353,6 +459,9 @@ class BatchUploader:
total_failed = 0 total_failed = 0
failed_files = [] failed_files = []
if not dry_run:
self._ensure_authenticated()
for project_key, project_images in project_groups.items(): for project_key, project_images in project_groups.items():
self.logger.info(f"🚀 Upload Projekt '{project_key}': {len(project_images)} Bilder") self.logger.info(f"🚀 Upload Projekt '{project_key}': {len(project_images)} Bilder")
@ -381,12 +490,17 @@ class BatchUploader:
))) )))
# Ein Upload-Request pro Projekt # Ein Upload-Request pro Projekt
payload = {
'metadata': json.dumps(backend_metadata),
'consents': json.dumps(consents_payload)
}
response = self.session.post( response = self.session.post(
f"{self.backend_url}/upload/batch", self._api_url('/upload/batch'),
files=files, files=files,
data={'metadata': json.dumps(backend_metadata)}, data=payload,
timeout=120, headers=self._authorized_headers(),
auth=self.auth timeout=120
) )
# Files schließen # Files schließen
@ -544,7 +658,7 @@ class BatchUploader:
def test_connection(self) -> bool: def test_connection(self) -> bool:
"""Testet Verbindung zum Backend (mit optionaler Auth)""" """Testet Verbindung zum Backend (mit optionaler Auth)"""
try: 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 return response.status_code == 200
except Exception as e: except Exception as e:
self.logger.error(f"Verbindungstest fehlgeschlagen: {e}") self.logger.error(f"Verbindungstest fehlgeschlagen: {e}")
@ -586,10 +700,10 @@ Beispiele:
default=DEFAULT_BACKEND_URL, default=DEFAULT_BACKEND_URL,
help=f'Backend URL (Standard: {DEFAULT_BACKEND_URL})') help=f'Backend URL (Standard: {DEFAULT_BACKEND_URL})')
parser.add_argument('--user', parser.add_argument('--user', '--username', dest='username',
help='HTTP Basic Auth Benutzername (optional)') help='Admin-Benutzername für Session-Login (erforderlich für Upload)')
parser.add_argument('--password', 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', parser.add_argument('--no-recursive',
action='store_true', action='store_true',
help='Nicht rekursiv in Unterverzeichnisse') help='Nicht rekursiv in Unterverzeichnisse')
@ -598,6 +712,17 @@ Beispiele:
action='store_true', action='store_true',
help='Nur Analyse, kein Upload') 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', parser.add_argument('--verbose', '-v',
action='store_true', action='store_true',
help='Verbose Output') help='Verbose Output')
@ -609,9 +734,24 @@ Beispiele:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: 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 # Verzeichnis validieren
directory = Path(args.directory).resolve() 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) # Verbindung testen (nur bei echtem Upload)
if not args.dry_run: if not args.dry_run:
@ -620,6 +760,8 @@ Beispiele:
logger.error("❌ Backend nicht erreichbar!") logger.error("❌ Backend nicht erreichbar!")
return 1 return 1
logger.info("✅ Backend erreichbar") logger.info("✅ Backend erreichbar")
uploader.ensure_admin_session()
logger.info("✅ Admin-Session aktiv")
else: else:
logger.info("🔍 Dry-Run Mode - Überspringe Verbindungstest") logger.info("🔍 Dry-Run Mode - Überspringe Verbindungstest")
@ -651,6 +793,7 @@ Beispiele:
images, images,
args.titel, args.titel,
args.name, args.name,
consents_config,
args.dry_run args.dry_run
) )

280
scripts/create_admin_user.sh Executable file
View 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
View File

@ -15,16 +15,11 @@ echo "curl http://localhost:5000/api/groups"
# Beispiel 1: Einfacher Upload # Beispiel 1: Einfacher Upload
echo -e "\n2. 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 # Beispiel 2: Erweiterte Optionen
echo -e "\n3. Mit allen Optionen:" echo -e "\n3. Mit allen Optionen:"
echo "python batch_uploader.py /home/user/photos \\" 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"
echo " --titel \"Urlaubsbilder 2024\" \\"
echo " --name \"Max Mustermann\" \\"
echo " --backend http://localhost:5000 \\"
echo " --chunk-size 10 \\"
echo " --verbose"
# Beispiel 3: Dry Run # Beispiel 3: Dry Run
echo -e "\n4. Dry Run (Analyse ohne Upload):" 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 # Beispiel 4: Große Sammlung
echo -e "\n5. Große Sammlung optimiert:" echo -e "\n5. Große Sammlung optimiert:"
echo "python batch_uploader.py /massive/photo/archive \\" echo "python batch_uploader.py /massive/photo/archive --titel \"Foto Archiv\" --user admin --password 'SehrSicher123!' --verbose"
echo " --titel \"Foto Archiv\" \\"
echo " --chunk-size 3 \\"
echo " --verbose"
# Test-Verzeichnis erstellen # Test-Verzeichnis erstellen
echo -e "\n6. Test-Verzeichnis erstellen:" echo -e "\n6. Test-Verzeichnis erstellen:"

View File

@ -121,15 +121,16 @@ def run_test_commands():
print(f"cd {Path.cwd()}") print(f"cd {Path.cwd()}")
print() print()
credentials = "--user admin --password 'SehrSicher123!'"
commands = [ commands = [
"# 1. Dry-Run Test (Neue Struktur)", "# 1. Dry-Run Test (Neue Struktur)",
f"python3 batch_uploader.py {TEST_DIR} --dry-run --verbose", f"python3 batch_uploader.py {TEST_DIR} --dry-run --verbose",
"", "",
"# 2. Einzelnes Projekt testen", "# 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)", "# 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", "# 4. Backend Status prüfen",
"curl http://localhost:5000/api/groups", "curl http://localhost:5000/api/groups",