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

View File

@ -1,5 +1,27 @@
# Changelog
## [Unreleased] - Branch: feature/security
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
#### Backend
- ✅ **Server-Side Sessions + CSRF**: Replaced Bearer-token auth with HttpOnly session cookies backed by SQLite, added `requireAdminAuth` + `requireCsrf` middlewares, and exposed `GET /auth/csrf-token` for clients.
- ✅ **New Auth Lifecycle**: Added `GET /auth/setup/status`, `POST /auth/setup/initial-admin`, `POST /auth/login`, `POST /auth/logout`, `POST /auth/change-password`, and `POST /api/admin/users` to support onboarding, login, rotation, and creating additional admins.
- ✅ **Admin Directory**: Introduced `admin_users` table, repository, and `AdminAuthService` (hash/verify, forced password change flag, audit-friendly responses) plus Jest coverage for the new flows.
- ✅ **OpenAPI & Swagger Stability**: Regenerate spec on dev start only, ignore `docs/openapi.json` in nodemon watches, and expose Swagger UI reliably at `http://localhost:5001/api/docs/`.
#### Frontend
- ✅ **Admin Session Context**: New `AdminSessionProvider` manages setup/login state, CSRF persistence, and guards moderation routes via `AdminSessionGate`.
- ✅ **Force Password Change UX**: Added `ForcePasswordChangeForm`, change-password API helper, and conditional gate that blocks moderation access until the first login password is rotated.
- ✅ **Management UI Updates**: Moderation/management pages now assume cookie-based auth, automatically attach CSRF headers, and gracefully handle session expiry.
#### Tooling & Scripts
- ✅ **API-Driven CLI**: Replaced the legacy Node-only helper with `scripts/create_admin_user.sh`, which can bootstrap the first admin or log in via API to add additional admins from any Linux machine.
- ✅ **Docker & Docs Alignment**: Updated dev/prod compose files, Nginx configs, and `README*`/`AUTHENTICATION.md`/`frontend/MIGRATION-GUIDE.md` to describe the new security model and CLI workflow.
- ✅ **Feature Documentation**: Added `FeatureRequests/FEATURE_PLAN-security.md` + `FEATURE_TESTPLAN-security.md` outlining design, validation steps, and residual follow-ups.
---
## [Unreleased] - Branch: feature/SocialMedia
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
@ -56,7 +78,7 @@
- ✅ **OpenAPI Auto-Generation**:
- Automatic spec generation on backend start (dev mode)
- Swagger UI available at `/api/docs` in development
- Swagger UI available at `/api/docs/` in development
- Skip generation in test and production modes
#### Bug Fixes

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
-->
1. erstelle ein Branch namens `feature/security` aus dem aktuellen `main` Branch.
2. erstelle eine Datei `FeatureRequests/FEATURE_PLAN-security.md` in der du die Umsetzungsaufgaben dokumentierst (siehe unten) und darin die TODO Liste erstellst und aktuallisierst.
3. Stelle mir Fragen bezüglich der Umsetzung
4. Verstehe, wie bisher im Frontend die UI aufgebaut ist (modular, keine inline css, globale app.css)
5. Implementiere die untenstehenden Aufgaben Schritt für Schritt.
# FEATURE_REQUEST: Security — Server-seitige Sessions für Admin-API
Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler)

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
2. ✅ **Konsistente API-Struktur:** Klare, REST-konforme API-Organisation für einfache KI-Navigation
3. ✅ **Single Source of Truth:** `routeMappings.js` als zentrale Route-Konfiguration
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs` (dev-only)
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs/` (dev-only)
5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing
6. ✅ **API Security:** Bearer Token Authentication für Admin-Endpoints
@ -140,7 +140,7 @@ npm run dev
# 4. Tests schreiben: tests/api/newRoute.test.js
npm test
# 5. Swagger UI: http://localhost:5001/api/docs
# 5. Swagger UI: http://localhost:5001/api/docs/
```
---

View File

@ -21,7 +21,7 @@
## Anforderungen an das Feature
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.
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).
@ -30,7 +30,7 @@
## Minimaler Scope (MVP)
- 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.
---

View File

@ -6,7 +6,7 @@
Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen:
- **Authentication**: Alle Admin-Endpoints benötigen jetzt Bearer Token
- **Authentication**: Admin-Endpoints laufen jetzt über serverseitige Sessions + CSRF Tokens
- **Route-Struktur**: Einige Pfade haben sich geändert (Single Source of Truth: `routeMappings.js`)
- **Error Handling**: Neue HTTP-Status-Codes (403 für Auth-Fehler)
@ -36,7 +36,7 @@ docker compose -f docker/dev/docker-compose.yml up -d
- **Backend**: http://localhost:5001 (API)
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
- **Slideshow**: http://localhost:3000/slideshow
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
- **Moderation**: http://localhost:3000/moderation (Login über Admin Session)
### Logs verfolgen
```bash
@ -55,7 +55,7 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich
**Massive API-Änderungen im November 2025:**
- Bearer Token Authentication für alle Admin-Endpoints
- Session + CSRF Authentication für alle Admin-Endpoints
- Route-Pfade umstrukturiert (siehe `routeMappings.js`)
- Neue Error-Response-Formate
@ -72,7 +72,7 @@ Siehe auch: **`backend/src/routes/README.md`** für vollständige API-Übersicht
**Wichtige Route-Gruppen:**
- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints
- `/api/manage/:token` - Self-Service Management Portal (UUID-Token)
- `/api/admin/*` - Admin-Endpoints (Bearer Token Authentication)
- `/api/admin/*` - Admin-Endpoints (Session + CSRF Authentication)
- `/api/system/migration/*` - Datenbank-Migrationen
**⚠️ Express Route-Reihenfolge beachten:**
@ -91,14 +91,28 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
**Zwei Auth-Systeme parallel:**
1. **Admin API (Bearer Token)**:
1. **Admin API (Session + CSRF)**:
```bash
# .env konfigurieren:
ADMIN_API_KEY=your-secure-key-here
# API-Aufrufe:
curl -H "Authorization: Bearer your-secure-key-here" \
http://localhost:5001/api/admin/groups
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
# Initialen Admin anlegen (falls benötigt)
curl -c cookies.txt http://localhost:5001/auth/setup/status
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' \
http://localhost:5001/auth/setup/initial-admin
# Login + CSRF Token holen
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' \
http://localhost:5001/auth/login
CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')
# Authentifizierter Admin-Request
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
http://localhost:5001/api/admin/groups
```
2. **Management Portal (UUID Token)**:
@ -109,13 +123,22 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
📖 **Vollständige Doku**: `AUTHENTICATION.md`
#### Admin-Hinweise: Logout & neue Nutzer
- **Logout:** Bis ein eigener Button im UI existiert, kann die Session jederzeit über den vorhandenen Endpoint beendet werden, z. B. in der Browser-Konsole:
```js
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
```
Alternativ per CLI: `curl -b cookies.txt -X POST http://localhost:5001/auth/logout`. Danach ist das `sid`-Cookie entfernt und die Moderationsseite zeigt wieder den Login.
- **Weiterer Admin:** `npm run create-admin -- --username zweiteradmin --password 'SuperPasswort123!' [--role admin --require-password-change]` oder alternativ `./scripts/create_admin_user.sh --username zweiteradmin --password 'SuperPasswort123!' [...]` ruft das Skript (`backend/src/scripts/createAdminUser.js`) auf und legt einen weiteren User an. Das Skript prüft Duplikate, nutzt dieselben Bcrypt-Runden wie das Backend und kann bei Bedarf weiterhin über die DB nachvollzogen werden. Falls du lieber manuell arbeitest, kannst du wie bisher einen Hash erzeugen und direkt in `admin_users` einfügen.
### OpenAPI-Spezifikation
Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert:
```bash
# Generiert: backend/docs/openapi.json
# Swagger UI: http://localhost:5001/api/docs
# Swagger UI: http://localhost:5001/api/docs/
# Manuelle Generierung:
cd backend
@ -157,7 +180,8 @@ router.get('/example', async (req, res) => {
- `repositories/GroupRepository.js` - Consent-Management & CRUD
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
- `routes/batchUpload.js` - Upload mit Consent-Validierung
- `middlewares/auth.js` - Admin Authentication (Bearer Token)
- `middlewares/session.js` - Express-Session + SQLite Store
- `middlewares/auth.js` - Admin Session-Guard & CSRF-Pflicht
- `database/DatabaseManager.js` - Automatische Migrationen
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik

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
- CI/CD ready with proper teardown and cleanup
- **🔒 Admin API Authentication** (Nov 16):
- Bearer token authentication for all admin endpoints
- Secure ADMIN_API_KEY environment variable configuration
- **🔒 Admin Session Authentication** (Nov 16):
- Server-managed HTTP sessions for all admin/system endpoints
- CSRF protection on every mutating request via `X-CSRF-Token`
- Secure `ADMIN_SESSION_SECRET` configuration keeps cookies tamper-proof
- Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
- 403 Forbidden responses for missing/invalid tokens
- Session-aware moderation UI with login + first-admin setup wizard
- Complete authentication documentation in `AUTHENTICATION.md`
- Ready for production deployment with token rotation support
- **📋 API Route Documentation** (Nov 16):
- Single Source of Truth: `backend/src/routes/routeMappings.js`
@ -206,11 +206,12 @@ The application automatically generates optimized preview thumbnails for all upl
### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication)
- **Authentication Methods**:
- **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
- **API Direct Access**: Bearer Token via `Authorization: Bearer <ADMIN_API_KEY>` header
- See `AUTHENTICATION.md` for detailed setup instructions
- **Access**: `http://localhost/moderation` (requires admin session)
- **Authentication Flow**:
- Built-in login form establishes a server session stored in HttpOnly cookies
- First-time setup wizard creates the initial admin user once `ADMIN_SESSION_SECRET` is configured
- CSRF token must be included (header `X-CSRF-Token`) for any mutating admin API call
- `AUTHENTICATION.md` documents CLI/cURL examples for managing sessions and CSRF tokens
- **Protected Endpoints**: All `/api/admin/*` routes require authentication
- **Features**:
- Review pending image groups before public display

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

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 = {
testEnvironment: 'node',
coverageDirectory: 'coverage',
setupFiles: ['<rootDir>/tests/env.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/index.js', // Server startup

View File

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

View File

@ -5,18 +5,22 @@ const fs = require('fs');
class DatabaseManager {
constructor() {
this.db = null;
// Use in-memory database for tests, file-based for production
if (process.env.NODE_ENV === 'test') {
this.dbPath = ':memory:';
} else {
// Place database file under data/db
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
}
this.dbPath = null;
this.schemaPath = path.join(__dirname, 'schema.sql');
}
getDatabasePath() {
if (process.env.NODE_ENV === 'test') {
return ':memory:';
}
return path.join(__dirname, '../data/db/image_uploader.db');
}
async initialize() {
try {
if (!this.dbPath) {
this.dbPath = this.getDatabasePath();
}
// Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
if (this.dbPath !== ':memory:') {
const dataDir = path.dirname(this.dbPath);
@ -47,8 +51,10 @@ class DatabaseManager {
// Run database migrations (automatic on startup)
await this.runMigrations();
// Generate missing previews for existing images (skip in test mode)
if (process.env.NODE_ENV !== 'test') {
const skipPreviewGeneration = ['1', 'true', 'yes'].includes(String(process.env.SKIP_PREVIEW_GENERATION || '').toLowerCase());
// Generate missing previews for existing images (skip in test mode or when explicitly disabled)
if (process.env.NODE_ENV !== 'test' && !skipPreviewGeneration) {
await this.generateMissingPreviews();
}
@ -167,6 +173,31 @@ class DatabaseManager {
END
`);
console.log('✓ Trigger erstellt');
// Admin Users Tabelle (für Session-Authentication)
await this.run(`
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_active BOOLEAN NOT NULL DEFAULT 1,
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username)');
await this.run(`
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
AFTER UPDATE ON admin_users
FOR EACH ROW
BEGIN
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
console.log('✓ Admin Users Tabelle erstellt');
console.log('✅ Datenbank-Schema vollständig erstellt');
} catch (error) {
@ -188,6 +219,19 @@ class DatabaseManager {
});
}
// Execute multi-statement SQL scripts (z. B. Migrationen mit Triggern)
exec(sql) {
return new Promise((resolve, reject) => {
this.db.exec(sql, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// Promise-wrapper für sqlite3.get
get(sql, params = []) {
return new Promise((resolve, reject) => {
@ -373,28 +417,26 @@ class DatabaseManager {
// Execute migration in a transaction
await this.run('BEGIN TRANSACTION');
// Remove comments (both line and inline) before splitting
// Remove comments (both line and inline) to avoid sqlite parser issues
const cleanedSql = sql
.split('\n')
.map(line => {
// Remove inline comments (everything after --)
const commentIndex = line.indexOf('--');
if (commentIndex !== -1) {
return line.substring(0, commentIndex);
}
return line;
})
.join('\n');
// Split by semicolon and execute each statement
const statements = cleanedSql
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
for (const statement of statements) {
await this.run(statement);
.join('\n')
.trim();
if (!cleanedSql) {
console.warn(` ⚠️ Migration ${file} enthält keinen ausführbaren SQL-Code, übersprungen`);
await this.run('COMMIT');
continue;
}
await this.exec(cleanedSql);
// Record migration
await this.run(

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

@ -47,4 +47,26 @@ AFTER UPDATE ON groups
FOR EACH ROW
BEGIN
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Admin Users Tabelle zur Verwaltung von Backend-Admins
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_active BOOLEAN NOT NULL DEFAULT 1,
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
AFTER UPDATE ON admin_users
FOR EACH ROW
BEGIN
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

View File

@ -1,49 +1,19 @@
/**
* Admin Authentication Middleware
* Validates Bearer token from Authorization header against ADMIN_API_KEY env variable
* Validates server-side session for admin users
*/
const requireAdminAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
// Check if Authorization header exists
if (!authHeader) {
const sessionUser = req.session && req.session.user;
if (!sessionUser || sessionUser.role !== 'admin') {
return res.status(403).json({
error: 'Zugriff verweigert',
message: 'Authorization header fehlt'
reason: 'SESSION_REQUIRED'
});
}
// Check if it's a Bearer token
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(403).json({
error: 'Zugriff verweigert',
message: 'Ungültiges Authorization Format. Erwartet: Bearer <token>'
});
}
const token = parts[1];
const adminKey = process.env.ADMIN_API_KEY;
// Check if ADMIN_API_KEY is configured
if (!adminKey) {
console.error('⚠️ ADMIN_API_KEY nicht in .env konfiguriert!');
return res.status(500).json({
error: 'Server-Konfigurationsfehler',
message: 'Admin-Authentifizierung nicht konfiguriert'
});
}
// Validate token
if (token !== adminKey) {
return res.status(403).json({
error: 'Zugriff verweigert',
message: 'Ungültiger Admin-Token'
});
}
// Token valid, proceed to route
res.locals.adminUser = sessionUser;
next();
};

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 fileUpload = require("express-fileupload");
const cors = require("./cors");
const session = require("./session");
const applyMiddlewares = (app) => {
app.use(fileUpload());
app.use(cors);
app.use(session);
// JSON Parser für PATCH/POST Requests
app.use(express.json());
};

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`
**Zugriff:** http://localhost:5000/api/docs (nur dev-mode)
**Zugriff:** http://localhost:5001/api/docs/ (nur dev-mode)
### Was wird generiert?
@ -321,7 +321,7 @@ npm run test-openapi
### Swagger UI öffnen
```
http://localhost:5000/api/docs
http://localhost:5001/api/docs/
```
**Hinweis:** Nur im Development-Modus verfügbar!

View File

@ -4,14 +4,77 @@ const DeletionLogRepository = require('../repositories/DeletionLogRepository');
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
const GroupRepository = require('../repositories/GroupRepository');
const GroupCleanupService = require('../services/GroupCleanupService');
const AdminAuthService = require('../services/AdminAuthService');
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
// GroupCleanupService ist bereits eine Instanz, keine Klasse
const cleanupService = GroupCleanupService;
// Apply admin authentication to ALL routes in this router
router.use(requireAdminAuth);
router.use(requireCsrf);
router.post('/users', async (req, res) => {
/*
#swagger.tags = ['Admin - Users']
#swagger.summary = 'Create a new admin user'
#swagger.description = 'Adds an additional admin (or auditor) via API'
#swagger.requestBody = {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string', example: 'admin2' },
password: { type: 'string', example: 'SehrSicher123!' },
role: { type: 'string', example: 'admin' },
requirePasswordChange: { type: 'boolean', example: true }
}
}
}
}
}
#swagger.responses[201] = {
description: 'Admin user created',
schema: {
success: true,
user: {
id: 5,
username: 'admin2',
role: 'admin',
requiresPasswordChange: false
}
}
}
*/
try {
const { username, password, role, requirePasswordChange } = req.body || {};
const user = await AdminAuthService.createAdminUser({
username,
password,
role,
requiresPasswordChange: Boolean(requirePasswordChange)
});
res.status(201).json({
success: true,
user
});
} catch (error) {
console.error('[Admin API] create user failed:', error.message);
if (['USERNAME_REQUIRED', 'PASSWORD_TOO_WEAK'].includes(error.message)) {
return res.status(400).json({ error: error.message });
}
if (error.message === 'USERNAME_IN_USE') {
return res.status(409).json({ error: 'USERNAME_IN_USE' });
}
res.status(500).json({ error: 'CREATE_ADMIN_FAILED' });
}
});
router.get('/deletion-log', async (req, res) => {
/*

165
backend/src/routes/auth.js Normal file
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 dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
// Schütze alle Consent-Routes mit Admin-Auth
router.use(requireAdminAuth);
router.use(requireCsrf);
// ============================================================================
// Social Media Platforms

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

4
backend/tests/env.js Normal file
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
process.env.NODE_ENV = 'test';
process.env.PORT = 5001;
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
try {
// Create and initialize server

View File

@ -3,6 +3,11 @@
* Initialize server singleton here
*/
// Ensure test environment variables are set before any application modules load
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
process.env.PORT = process.env.PORT || 5001;
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
const Server = require('../src/server');
// Singleton pattern - initialize only once
@ -13,10 +18,6 @@ async function initializeTestServer() {
if (!app) {
console.log('🔧 Initializing test server (one-time)...');
process.env.NODE_ENV = 'test';
process.env.PORT = 5001;
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
serverInstance = new Server(5001);
app = await serverInstance.initializeApp();

View File

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

View File

@ -1,81 +1,148 @@
const { requireAdminAuth } = require('../../src/middlewares/auth');
const AdminAuthService = require('../../src/services/AdminAuthService');
const AdminUserRepository = require('../../src/repositories/AdminUserRepository');
const dbManager = require('../../src/database/DatabaseManager');
describe('Auth Middleware Unit Test', () => {
describe('Auth Middleware Unit Test (Session based)', () => {
let req, res, next;
beforeEach(() => {
req = { headers: {} };
req = { session: null };
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
json: jest.fn(),
locals: {}
};
next = jest.fn();
process.env.ADMIN_API_KEY = 'test-key-123';
});
test('should reject missing Authorization header', () => {
test('should reject when no session exists', () => {
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Zugriff verweigert',
message: 'Authorization header fehlt'
reason: 'SESSION_REQUIRED'
})
);
expect(next).not.toHaveBeenCalled();
});
test('should reject invalid Bearer format', () => {
req.headers.authorization = 'Invalid token';
test('should reject when session user is missing', () => {
req.session = {};
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Ungültiges Authorization Format')
})
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
);
expect(next).not.toHaveBeenCalled();
});
test('should reject wrong token', () => {
req.headers.authorization = 'Bearer wrong-token';
test('should reject non-admin roles', () => {
req.session = { user: { id: 1, role: 'viewer' } };
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Ungültiger Admin-Token'
})
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
);
expect(next).not.toHaveBeenCalled();
});
test('should allow valid token', () => {
req.headers.authorization = 'Bearer test-key-123';
test('should pass through for admin sessions and expose user on locals', () => {
const adminUser = { id: 1, role: 'admin', username: 'testadmin' };
req.session = { user: adminUser };
requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
test('should handle missing ADMIN_API_KEY', () => {
delete process.env.ADMIN_API_KEY;
req.headers.authorization = 'Bearer any-token';
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Server-Konfigurationsfehler'
})
);
expect(next).not.toHaveBeenCalled();
expect(res.locals.adminUser).toEqual(adminUser);
});
});
describe('AdminAuthService', () => {
beforeEach(async () => {
await dbManager.run('DELETE FROM admin_users');
});
afterEach(async () => {
await dbManager.run('DELETE FROM admin_users');
});
test('needsInitialSetup reflects admin count', async () => {
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(true);
await AdminAuthService.createInitialAdmin({
username: 'existing',
password: 'SuperSecure123!'
});
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(false);
});
test('createInitialAdmin validates input and detects completed setup', async () => {
await expect(
AdminAuthService.createInitialAdmin({ username: '', password: 'SuperSecure123!' })
).rejects.toThrow('USERNAME_REQUIRED');
await expect(
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'short' })
).rejects.toThrow('PASSWORD_TOO_WEAK');
await AdminAuthService.createInitialAdmin({ username: 'seed', password: 'SuperSecure123!' });
await expect(
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'SuperSecure123!' })
).rejects.toThrow('SETUP_ALREADY_COMPLETED');
});
test('createInitialAdmin persists normalized admin when setup allowed', async () => {
const result = await AdminAuthService.createInitialAdmin({
username: 'TestAdmin',
password: 'SuperSecure123!'
});
expect(result.username).toBe('testadmin');
expect(result.role).toBe('admin');
const stored = await AdminUserRepository.getByUsername('testadmin');
expect(stored).toMatchObject({ username: 'testadmin', role: 'admin', is_active: 1 });
});
test('verifyCredentials handles missing users and password mismatches', async () => {
await expect(AdminAuthService.verifyCredentials('admin', 'pw')).resolves.toBeNull();
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
await AdminUserRepository.createAdminUser({
username: 'admin',
passwordHash: hash,
role: 'admin',
requiresPasswordChange: false
});
await expect(AdminAuthService.verifyCredentials('admin', 'wrong')).resolves.toBeNull();
});
test('verifyCredentials returns sanitized user for valid credentials', async () => {
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
await AdminUserRepository.createAdminUser({
username: 'admin',
passwordHash: hash,
role: 'admin',
requiresPasswordChange: true
});
const result = await AdminAuthService.verifyCredentials('admin', 'SuperSecure123!');
expect(result).toEqual({
id: expect.any(Number),
username: 'admin',
role: 'admin',
requiresPasswordChange: true
});
});
});

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

View File

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

View File

@ -23,9 +23,6 @@ RUN chmod +x ./env.sh
# Copy nginx configuration for development
COPY docker/dev/frontend/nginx.conf /etc/nginx/conf.d/default.conf
# Copy htpasswd file for authentication
COPY docker/dev/frontend/config/htpasswd /etc/nginx/.htpasswd
# Make /app owned by the non-root user, then run npm as that user so
# node_modules are created with the correct owner and we avoid an expensive
# recursive chown later.

View File

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

View File

@ -16,7 +16,6 @@ services:
environment:
- API_URL=http://backend:5000
- CLIENT_URL=http://localhost
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
networks:
- npm-nw
@ -36,7 +35,8 @@ services:
- prod-internal
environment:
- NODE_ENV=production
- ADMIN_API_KEY=${ADMIN_API_KEY}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
networks:
npm-nw:

View File

@ -14,9 +14,6 @@ FROM nginx:stable-alpine
RUN rm -rf /etc/nginx/conf.d
COPY docker/prod/frontend/nginx.conf /etc/nginx/nginx.conf
# Copy htpasswd file for authentication
COPY docker/prod/frontend/config/htpasswd /etc/nginx/.htpasswd
# Static build
COPY --from=build /app/build /usr/share/nginx/html

View File

@ -51,19 +51,6 @@ http {
client_max_body_size 100M;
}
# Protected API - Moderation API routes (password protected) - must come before /groups
# Keep this route protected and proxy to backend if moderation endpoints exist there.
location /moderation/groups {
auth_basic "Restricted Area - Moderation API";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://image-uploader-backend:5000/moderation/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups API routes (NO PASSWORD PROTECTION)
location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ {
proxy_pass http://image-uploader-backend:5000;
@ -92,11 +79,8 @@ http {
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
}
# Protected routes - Moderation (password protected)
# Moderation UI (session-protected within the app)
location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;

View File

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

View File

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

View File

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

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-secondary { background:#6c757d; color:white; }
.btn-secondary:hover { background:#5a6268; }
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
.btn-outline-secondary:hover:not(:disabled) { background:#6c757d; color:white; }
.btn-success { background:#28a745; color:white; }
.btn-success:hover { background:#218838; }
.btn-warning { background:#ffc107; color:#212529; }
@ -60,6 +62,7 @@
.btn-danger { background:#dc3545; color:white; }
.btn-danger:hover { background:#c82333; }
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
.btn:disabled { opacity:0.65; cursor:not-allowed; }
/* Modal */
.image-modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); display:flex; align-items:center; justify-content:center; z-index:1000; padding:20px; }
@ -93,3 +96,9 @@
.home-button { border-radius:25px; padding:12px 30px; font-size:16px; font-weight:500; text-transform:none; border:2px solid #4CAF50; color:#4CAF50; background-color: transparent; cursor:pointer; }
.empty-state { text-align:center; padding:60px 20px; }
.loading-container { text-align:center; padding:60px 20px; }
/* Admin Auth */
.admin-auth-wrapper { min-height: 70vh; display:flex; align-items:center; justify-content:center; padding:40px 16px; }
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
.admin-auth-form { width:100%; }
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }

View File

@ -1,5 +1,6 @@
import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
// Pages
import MultiUploadPage from './Components/Pages/MultiUploadPage';
@ -13,8 +14,9 @@ import FZF from './Components/Pages/404Page.js'
function App() {
return (
<Router>
<Routes>
<AdminSessionProvider>
<Router>
<Routes>
<Route path="/" exact element={<MultiUploadPage />} />
<Route path="/slideshow" element={<SlideshowPage />} />
<Route path="/groups/:groupId" element={<PublicGroupImagesPage />} />
@ -23,8 +25,9 @@ function App() {
<Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} />
<Route path="*" element={<FZF />} />
</Routes>
</Router>
</Routes>
</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';
// Services
import { adminGet } from '../../../services/adminApi';
import { handleAdminError } from '../../../services/adminErrorHandler';
import { getActiveSocialMediaPlatforms } from '../../../services/socialMediaApi';
import InfoIcon from '@mui/icons-material/Info';
import FacebookIcon from '@mui/icons-material/Facebook';
import InstagramIcon from '@mui/icons-material/Instagram';
@ -56,11 +55,11 @@ function ConsentCheckboxes({
const fetchPlatforms = async () => {
try {
const data = await adminGet('/api/admin/social-media/platforms');
const data = await getActiveSocialMediaPlatforms();
setPlatforms(data);
setError(null);
} catch (error) {
await handleAdminError(error, 'Plattformen laden');
console.error('Fehler beim Laden der Plattformen:', error);
setError('Plattformen konnten nicht geladen werden');
} finally {
setLoading(false);

View File

@ -5,6 +5,8 @@ import { Container, Box } from '@mui/material';
// Services
import { adminGet } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar';
@ -26,8 +28,12 @@ const ModerationGroupImagesPage = () => {
const [group, setGroup] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { isAuthenticated } = useAdminSession();
const loadGroup = useCallback(async () => {
const loadGroup = useCallback(async () => {
if (!isAuthenticated) {
return;
}
try {
setLoading(true);
const data = await adminGet(`/api/admin/groups/${groupId}`);
@ -57,51 +63,61 @@ const ModerationGroupImagesPage = () => {
} finally {
setLoading(false);
}
}, [groupId]);
}, [groupId, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
return;
}
loadGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupId]);
}, [isAuthenticated, loadGroup]);
if (loading) return <Loading />;
if (error) return <div className="moderation-error">{error}</div>;
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>;
const renderContent = () => {
if (loading) return <Loading />;
if (error) return <div className="moderation-error">{error}</div>;
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>;
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 (
<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>
<AdminSessionGate>
{renderContent()}
</AdminSessionGate>
);
};

View File

@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox } from '@mui/material';
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Typography } from '@mui/material';
import FilterListIcon from '@mui/icons-material/FilterList';
import Swal from 'sweetalert2/dist/sweetalert2.js';
// Services
import { adminGet, adminRequest, adminDownload } from '../../services/adminApi';
import { handleAdminError } from '../../services/adminErrorHandler';
import { getActiveSocialMediaPlatforms } from '../../services/socialMediaApi';
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar';
@ -30,19 +33,27 @@ const ModerationGroupsPage = () => {
});
const [platforms, setPlatforms] = useState([]);
const navigate = useNavigate();
const { isAuthenticated, logout, user } = useAdminSession();
const [logoutPending, setLogoutPending] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
return;
}
loadModerationGroups();
loadPlatforms();
}, []);
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
return;
}
loadModerationGroups();
}, [consentFilters]);
}, [consentFilters, isAuthenticated]);
const loadPlatforms = async () => {
try {
const data = await adminGet('/api/admin/social-media/platforms');
const data = await getActiveSocialMediaPlatforms();
setPlatforms(data);
} catch (error) {
await handleAdminError(error, 'Plattformen laden');
@ -146,7 +157,18 @@ const ModerationGroupsPage = () => {
};
const deleteGroup = async (groupId) => {
if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
const result = await Swal.fire({
icon: 'warning',
title: 'Gruppe löschen?',
text: 'Die Löschung kann nicht rückgängig gemacht werden.',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Ja, löschen',
cancelButtonText: 'Abbrechen'
});
if (!result.isConfirmed) {
return;
}
@ -158,6 +180,14 @@ const ModerationGroupsPage = () => {
setSelectedGroup(null);
setShowImages(false);
}
await Swal.fire({
icon: 'success',
title: 'Gruppe gelöscht',
text: 'Die Gruppe wurde vollständig entfernt.',
timer: 1800,
showConfirmButton: false
});
} catch (error) {
await handleAdminError(error, 'Gruppe löschen');
}
@ -193,161 +223,219 @@ const ModerationGroupsPage = () => {
}
};
if (loading) {
return <div className="moderation-loading">Lade Gruppen...</div>;
}
const handleLogoutClick = async () => {
try {
setLogoutPending(true);
await logout();
await Swal.fire({
icon: 'success',
title: 'Abgemeldet',
text: 'Die Admin-Session wurde beendet.',
timer: 1500,
showConfirmButton: false
});
} catch (logoutError) {
console.error('Logout fehlgeschlagen:', logoutError);
await Swal.fire({
icon: 'error',
title: 'Logout fehlgeschlagen',
text: logoutError?.message || 'Bitte später erneut versuchen.'
});
} finally {
setLogoutPending(false);
}
};
if (error) {
return <div className="moderation-error">{error}</div>;
}
const renderContent = () => {
if (loading) {
return <div className="moderation-loading">Lade Gruppen...</div>;
}
const pendingGroups = groups.filter(g => !g.approved);
const approvedGroups = groups.filter(g => g.approved);
if (error) {
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 (
<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' }}>
<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>
<AdminSessionGate>
{renderContent()}
</AdminSessionGate>
);
};

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 @@
/**
* Admin API Helper mit Bearer Token Authentication
*
* Verwendet für alle /api/admin/* und /api/system/* Endpoints
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const CSRF_STORAGE_KEY = 'piu.admin.csrfToken';
/**
* Führt einen fetch-Request mit Admin-Bearer-Token aus
* @param {string} url - Die URL (mit /api/admin/* oder /api/system/* Prefix)
* @param {object} options - Fetch options (method, body, headers, etc.)
* @returns {Promise<Response>}
*/
export const adminFetch = async (url, options = {}) => {
const token = process.env.REACT_APP_ADMIN_API_KEY;
if (!token) {
console.error('REACT_APP_ADMIN_API_KEY not configured!');
throw new Error('Admin API Token not configured');
let csrfToken = null;
if (typeof window !== 'undefined' && window.sessionStorage) {
csrfToken = window.sessionStorage.getItem(CSRF_STORAGE_KEY);
}
const persistCsrfToken = (token) => {
csrfToken = token;
if (typeof window !== 'undefined' && window.sessionStorage) {
if (token) {
window.sessionStorage.setItem(CSRF_STORAGE_KEY, token);
} else {
window.sessionStorage.removeItem(CSRF_STORAGE_KEY);
}
}
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`
};
return fetch(url, {
...options,
headers
});
};
/**
* Hilfsfunktion für GET-Requests mit automatischer JSON-Parsing und Error-Handling
* @param {string} url
* @returns {Promise<any>}
*/
export const adminGet = async (url) => {
const response = await adminFetch(url);
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 parseErrorResponse = async (response) => {
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
const error = new Error(payload?.error || payload?.message || response.statusText);
error.status = response.status;
error.reason = payload?.reason;
error.code = payload?.error || payload?.code;
error.payload = payload;
return error;
};
export const clearStoredCsrfToken = () => {
persistCsrfToken(null);
};
export const fetchCsrfToken = async () => {
const response = await fetch('/auth/csrf-token', {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
const data = await response.json();
if (data?.csrfToken) {
persistCsrfToken(data.csrfToken);
}
return data?.csrfToken;
};
const ensureCsrfToken = async () => {
if (!csrfToken) {
await fetchCsrfToken();
}
return csrfToken;
};
export const adminFetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
const headers = new Headers(options.headers || {});
const needsCsrf = options.requireCsrf !== false && !SAFE_METHODS.has(method);
if (needsCsrf) {
const token = await ensureCsrfToken();
if (token) {
headers.set('X-CSRF-Token', token);
}
} else if (csrfToken) {
headers.set('X-CSRF-Token', csrfToken);
}
const response = await fetch(url, {
...options,
method,
credentials: 'include',
headers
});
if (response.ok) {
return response;
}
// Attempt to refresh CSRF token if it might be invalid
if (response.status === 403 && needsCsrf) {
try {
await fetchCsrfToken();
} catch (error) {
// ignore, original error will be thrown below
}
}
throw await parseErrorResponse(response);
};
export const adminGet = async (url) => {
const response = await adminFetch(url, { method: 'GET', requireCsrf: false });
return response.json();
};
/**
* Hilfsfunktion für POST/PUT/PATCH/DELETE mit JSON body
* @param {string} url
* @param {string} method
* @param {object} body
* @returns {Promise<Response>}
*/
export const adminRequest = async (url, method, body = null) => {
const options = {
method,
@ -70,37 +116,81 @@ export const adminRequest = async (url, method, body = null) => {
}
const response = await adminFetch(url, options);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Unauthorized: Invalid or missing admin token');
}
if (response.status === 429) {
throw new Error('Too many requests: Rate limit exceeded');
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
};
/**
* Hilfsfunktion für Blob/File Downloads (CSV, PDF, etc.)
* @param {string} url
* @returns {Promise<Blob>}
*/
export const adminDownload = async (url) => {
const response = await adminFetch(url);
if (!response.ok) {
if (response.status === 403) {
throw new Error('Unauthorized: Invalid or missing admin token');
}
if (response.status === 429) {
throw new Error('Too many requests: Rate limit exceeded');
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const response = await adminFetch(url, { method: 'GET', requireCsrf: false });
return response.blob();
};
export const getSetupStatus = async () => {
const response = await fetch('/auth/setup/status', {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return response.json();
};
export const createInitialAdmin = async ({ username, password }) => {
const response = await fetch('/auth/setup/initial-admin', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
const data = await response.json();
if (data?.csrfToken) {
persistCsrfToken(data.csrfToken);
}
return data;
};
export const login = async ({ username, password }) => {
const response = await fetch('/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
const data = await response.json();
if (data?.csrfToken) {
persistCsrfToken(data.csrfToken);
}
return data;
};
export const logout = async () => {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
clearStoredCsrfToken();
};
export const changePassword = async ({ currentPassword, newPassword }) => {
const response = await adminFetch('/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ currentPassword, newPassword })
});
return response.json();
};

View File

@ -13,30 +13,44 @@ import Swal from 'sweetalert2/dist/sweetalert2.js';
export const handleAdminError = async (error, context = 'Operation') => {
console.error(`Admin API Error [${context}]:`, error);
// 403 Unauthorized - Admin Token fehlt oder ungültig
if (error.message.includes('Unauthorized') || error.message.includes('403')) {
const status = error?.status;
const reason = error?.reason || error?.code || error?.payload?.reason;
// Session missing or expired
if (status === 401 || reason === 'SESSION_REQUIRED') {
await Swal.fire({
icon: 'error',
title: 'Authentifizierung fehlgeschlagen',
html: `
<p><strong>Admin-Token fehlt oder ist ungültig.</strong></p>
<p>Bitte kontaktieren Sie den Administrator.</p>
<hr>
<small>
<strong>Technische Details:</strong><br>
- Prüfen Sie die REACT_APP_ADMIN_API_KEY Variable<br>
- Token muss mit Backend ADMIN_API_KEY übereinstimmen<br>
- Kontext: ${context}
</small>
`,
confirmButtonText: 'OK',
confirmButtonColor: '#d33'
icon: 'warning',
title: 'Anmeldung erforderlich',
text: 'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
confirmButtonText: 'Zum Login'
});
return;
}
// 429 Rate Limit
if (error.message.includes('Too many requests') || error.message.includes('429')) {
// CSRF token invalid or missing
if (status === 403 && (reason === 'CSRF_INVALID' || reason === 'CSRF_REQUIRED')) {
await Swal.fire({
icon: 'warning',
title: 'Sicherheitsüberprüfung fehlgeschlagen',
text: 'Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
confirmButtonText: 'Neu laden'
});
return;
}
// Generic forbidden
if (status === 403) {
await Swal.fire({
icon: 'error',
title: 'Keine Berechtigung',
text: 'Sie besitzen keine Berechtigung für diese Aktion.',
confirmButtonText: 'OK'
});
return;
}
// Rate limit
if (status === 429) {
await Swal.fire({
icon: 'warning',
title: 'Zu viele Anfragen',
@ -47,33 +61,33 @@ export const handleAdminError = async (error, context = 'Operation') => {
return;
}
// 404 Not Found
if (error.message.includes('404')) {
// Not found
if (status === 404) {
await Swal.fire({
icon: 'error',
title: 'Nicht gefunden',
text: `Die angeforderte Ressource wurde nicht gefunden.`,
text: 'Die angeforderte Ressource wurde nicht gefunden.',
confirmButtonText: 'OK'
});
return;
}
// 500 Server Error
if (error.message.includes('500')) {
// Server error
if (status && status >= 500) {
await Swal.fire({
icon: 'error',
title: 'Server-Fehler',
text: 'Ein interner Server-Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
text: 'Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
confirmButtonText: 'OK'
});
return;
}
// Generischer Fehler
// Generic fallback
await Swal.fire({
icon: 'error',
title: `Fehler: ${context}`,
text: error.message || 'Ein unbekannter Fehler ist aufgetreten.',
text: error?.message || 'Ein unbekannter Fehler ist aufgetreten.',
confirmButtonText: 'OK'
});
};

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.
@ -6,9 +16,10 @@ Ein Python-Skript für automatischen Batch-Upload von Bildern mit Metadaten-Extr
- 🔍 **Rekursives Verzeichnis-Scanning** nach unterstützten Bildformaten
- 📊 **Automatische Metadaten-Extraktion** aus EXIF-Daten und Pfad-Struktur
- 🚀 **Batch-Upload** mit konfigurierbarer Chunk-Größe
- 🚀 **Batch-Upload** mit automatischer Projekt-Gruppierung
- 📈 **Progress-Tracking** und Error-Handling
- 🏗️ **Strukturierte Metadaten** (Jahr, Titel, Beschreibung, Name)
- 🔐 **Admin-Session Login** mit CSRF-Schutz (entsprechend `AUTHENTICATION.md`)
## Installation
@ -25,10 +36,12 @@ pip install requests pillow
### Einfacher Upload
```bash
# Linux/macOS
python batch_uploader.py /path/to/images --titel "Meine Foto-Sammlung"
python batch_uploader.py /path/to/images --titel "Meine Foto-Sammlung" \
--user admin --password 'SehrSicher123!'
# Windows
python batch_uploader.py "C:\Users\username\Photos" --titel "Meine Foto-Sammlung"
python batch_uploader.py "C:\Users\username\Photos" --titel "Meine Foto-Sammlung" ^
--user admin --password "SehrSicher123!"
```
### Erweiterte Optionen
@ -38,30 +51,38 @@ python batch_uploader.py ./photos \
--titel "Urlaubsbilder 2024" \
--name "Max Mustermann" \
--backend http://localhost:5000 \
--chunk-size 10
--user admin --password 'SehrSicher123!' \
--social-media-consents consents.json
## Windows / WSL: Pfade mit Leerzeichen und Sonderzeichen
Windows-PowerShell (empfohlen):
```powershell
# Doppelte Anführungszeichen um den kompletten Pfad, Backslashes bleiben unverändert
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" --titel "Nextcloud Archive" --name "Lotz M." --verbose
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" \
--titel "Nextcloud Archive" --name "Lotz M." --verbose \
--user admin --password "SehrSicher123!"
```
Windows (CMD) ohne Anführungszeichen mit Escapes oder mit Anführungszeichen:
```bat
REM Mit Backslashes escapen (CMD):
python batch_uploader.py C:\Users\lotzm\Nextcloud2\HH\ DropFolder\ with\ quota\=NutzerBildUploads= --titel "Nextcloud Archive"
python batch_uploader.py C:\Users\lotzm\Nextcloud2\HH\ DropFolder\ with\ quota\=NutzerBildUploads= \
--titel "Nextcloud Archive" --user admin --password SehrSicher123!
REM Oder einfacher mit Anführungszeichen:
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" --titel "Nextcloud Archive"
python batch_uploader.py "C:\Users\lotzm\Nextcloud2\HH DropFolder with quota\=NutzerBildUploads=" \
--titel "Nextcloud Archive" --user admin --password "SehrSicher123!"
```
WSL / Linux (bash) Pfad in /mnt/c/... verwenden, ohne zusätzliche Backslashes in Quotes:
```bash
python3 batch_uploader.py "/mnt/c/Users/lotzm/Nextcloud2/HH DropFolder with quota/=NutzerBildUploads=" --titel "Nextcloud Archive" --name "Lotz M." --verbose
python3 batch_uploader.py "/mnt/c/Users/lotzm/Nextcloud2/HH DropFolder with quota/=NutzerBildUploads=" \
--titel "Nextcloud Archive" --name "Lotz M." --verbose \
--user admin --password 'SehrSicher123!'
# oder ohne Quotes, mit Backslash-Escapes:
python3 batch_uploader.py /mnt/c/Users/lotzm/Nextcloud2/HH\ DropFolder\ with\ quota/=NutzerBildUploads= --titel "Nextcloud Archive"
python3 batch_uploader.py /mnt/c/Users/lotzm/Nextcloud2/HH\ DropFolder\ with\ quota/=NutzerBildUploads= \
--titel "Nextcloud Archive" --user admin --password 'SehrSicher123!'
```
Hinweis:
@ -84,11 +105,44 @@ python batch_uploader.py ./images --no-recursive
| `--titel` | Standard-Titel für alle Bilder | Aus Pfad extrahiert |
| `--name` | Standard-Name für alle Bilder | Leer |
| `--backend` | Backend-URL | `http://localhost:5000` |
| `--chunk-size` | ~~Bilder pro Upload-Batch~~ (Deprecated) | ~~5~~ |
| `--user / --password` | Admin-Credentials für Session-Login | - |
| `--workshop-consent` / `--no-workshop-consent` | Zustimmung zur Anzeige in der Werkstatt | `True` |
| `--social-media-consents` | JSON-String oder Datei mit Social-Media-Consents | `[]` |
| `--no-recursive` | Nicht in Unterverzeichnisse | `False` |
| `--dry-run` | Nur Analyse, kein Upload | `False` |
| `--verbose` | Detailliertes Logging | `False` |
## Admin-Login & Consents
Der Batch-Uploader verwendet denselben Session-/CSRF-Flow wie die Admin-UI (siehe `AUTHENTICATION.md`).
1. **Admin-Benutzer vorbereiten** entweder über die UI oder per `./create_admin_user.sh`.
2. **Login-Daten übergeben** `--user admin --password '•••'`.
3. **Skript führt automatisch aus**:
- `POST /auth/login`
- `GET /auth/csrf-token` (falls nötig)
- `POST /api/upload/batch` mit `X-CSRF-Token`
### Consents setzen
- `workshopConsent` ist Pflicht. Standard ist `True`, kann via `--no-workshop-consent` deaktiviert werden.
- Social-Media-Consents können aus einer Datei oder einem JSON-String geladen werden:
```json
[
{ "platformId": 1, "consented": true },
{ "platformId": 2, "consented": false }
]
```
Aufruf-Beispiel:
```bash
python batch_uploader.py ./photos \
--user admin --password 'SehrSicher123!' \
--social-media-consents consents.json
```
## Metadaten-Extraktion
### Erwartete Struktur: `Photos/Jahr/Name/Projekt/dateiname.endung`
@ -217,7 +271,7 @@ Traumhafter Urlaub auf Mallorca mit Sonne, Strand und entspannten Momenten am Po
- **Connection-Timeout**: 10s für Backend-Test, 60s für Upload
- **File-Errors**: Automatisches Skip von beschädigten Bildern
- **Chunk-Failures**: Einzelne Chunks können fehlschlagen ohne Abbruch
- **Projekt-Fehler**: Fehler in einem Projekt stoppen den Gesamtprozess nicht
- **Retry-Logic**: Verwendet Session für Connection-Reuse
## Cross-Platform Support
@ -251,10 +305,8 @@ python batch_uploader.py "\\\\nas-server\\photos\\2024" --verbose
### Backend nicht erreichbar
```bash
### Backend Status prüfen
```bash
# Prüfe Backend-Status
curl http://localhost:5000/groups
curl http://localhost:5000/api/groups
# Backend starten
cd ../
@ -269,9 +321,10 @@ pip install --upgrade Pillow
### Performance bei großen Batches
```bash
# Kleinere Chunk-Size verwenden
python batch_uploader.py /photos --chunk-size 3
# Nach Jahr oder Name aufteilen
python batch_uploader.py /photos/2024/Familie_Schmidt \
--user admin --password 'SehrSicher123!'
# Progress verfolgen
python batch_uploader.py /photos --verbose
# Vorab prüfen
python batch_uploader.py /photos --dry-run --verbose
```

View File

@ -9,15 +9,8 @@ mit strukturierten Metadaten an das Image-Uploader Backend.
Features:
- Rekursives Verzeichnis-Scanning nach Bildern
- Metadaten-Extraktion aus Verzeichnis-/Dateinamen
- Batch-Upload an das Backen self.logger.info(f"📊 Upload abgeschlossen: {len(project_groups)} Gruppen erstellt")
return {
'total': total_images,
'successful': total_successful,
'failed': total_failed,
'failed_files': failed_files,
'project_groups': project_groups # Für Übersicht am Ende
}Progress-Tracking und Error-Handling
- Batch-Upload an das Backend mit Session-Authentifizierung
- Fortschritts-Tracking und Error-Handling
- EXIF-Daten Unterstützung (optional)
Usage:
@ -30,7 +23,7 @@ import json
import requests
import argparse
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from typing import Any, List, Dict, Optional, Tuple
import mimetypes
from PIL import Image, ExifTags
from PIL.ExifTags import TAGS
@ -39,11 +32,35 @@ from datetime import datetime
import logging
# Konfiguration
#DEFAULT_BACKEND_URL = "https://deinprojekt.lan.hobbyhimmel.de/api"
DEFAULT_BACKEND_URL = "http://localhost/api"
DEFAULT_BACKEND_URL = "http://localhost:5000"
SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def load_social_media_consents(input_value: Optional[str]) -> List[Dict[str, Any]]:
"""Lädt Social-Media-Consents aus JSON-String oder Datei"""
if not input_value:
return []
potential_path = Path(input_value)
try:
if potential_path.exists() and potential_path.is_file():
content = potential_path.read_text(encoding='utf-8')
else:
content = input_value
data = json.loads(content)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Ungültige Social-Media-Consents: {exc}") from exc
if isinstance(data, dict):
# Ein einzelnes Consent-Objekt erlauben
return [data]
if not isinstance(data, list):
raise ValueError("Social-Media-Consents müssen Liste oder Objekt sein")
return data
class ImageMetadataExtractor:
"""Extrahiert Metadaten aus Bildern und Verzeichnissen
@ -78,7 +95,7 @@ class ImageMetadataExtractor:
if not re.match(r'^(19|20)\d{2}$', jahr):
self.logger.debug(f"Ungültiges Jahr in Pfad: {jahr}")
# Versuche Jahr aus anderen Teilen zu extrahieren
jahr = self.extract_year_from_path(file_path)
jahr = self.extract_year_from_path(file_path) or ''
return {
'jahr': jahr,
@ -222,17 +239,97 @@ class ImageMetadataExtractor:
class BatchUploader:
"""Haupt-Klasse für Batch-Upload"""
def __init__(self, backend_url: str = DEFAULT_BACKEND_URL, user: str = None, password: str = None):
self.backend_url = backend_url.rstrip('/')
def __init__(self, backend_url: str = DEFAULT_BACKEND_URL,
username: Optional[str] = None,
password: Optional[str] = None):
self.base_url = backend_url.rstrip('/')
if self.base_url.endswith('/api'):
self.base_url = self.base_url[:-4]
self.api_base_url = f"{self.base_url}/api"
self.metadata_extractor = ImageMetadataExtractor()
self.logger = logging.getLogger(__name__)
# Session für Connection-Reuse
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Batch-Uploader/1.0'
'User-Agent': 'Batch-Uploader/2.0',
'Accept': 'application/json'
})
self.auth = (user, password) if user and password else None
self.username = username
self.password = password
self.csrf_token: Optional[str] = None
def _api_url(self, path: str) -> str:
path = path.lstrip('/')
return f"{self.api_base_url}/{path}"
def _auth_required(self) -> bool:
return bool(self.username and self.password)
def has_credentials(self) -> bool:
return self._auth_required()
def ensure_admin_session(self) -> None:
"""Public wrapper für Authentifizierung"""
self._ensure_authenticated()
def _ensure_authenticated(self) -> None:
if self.csrf_token:
return
if not self._auth_required():
raise ValueError("Admin-Benutzername und Passwort erforderlich für Upload")
login_url = f"{self.base_url}/auth/login"
self.logger.info("🔐 Melde Admin-Session an...")
response = self.session.post(
login_url,
json={'username': self.username, 'password': self.password},
timeout=20
)
if response.status_code != 200:
raise RuntimeError(
f"Login fehlgeschlagen ({response.status_code}): {response.text}"
)
try:
data = response.json()
except ValueError as exc:
raise RuntimeError("Login-Antwort konnte nicht gelesen werden") from exc
self.csrf_token = data.get('csrfToken')
if not self.csrf_token:
self._refresh_csrf_token()
else:
self.logger.debug("CSRF-Token aus Login-Response übernommen")
def _refresh_csrf_token(self) -> None:
csrf_url = f"{self.base_url}/auth/csrf-token"
response = self.session.get(csrf_url, timeout=10)
if response.status_code != 200:
raise RuntimeError(
f"CSRF-Token konnte nicht geladen werden ({response.status_code})"
)
try:
data = response.json()
except ValueError as exc:
raise RuntimeError("CSRF-Antwort konnte nicht gelesen werden") from exc
token = data.get('csrfToken')
if not token:
raise RuntimeError("Antwort enthielt kein csrfToken")
self.csrf_token = token
self.logger.debug("CSRF-Token aktualisiert")
def _authorized_headers(self) -> Dict[str, str]:
if not self.csrf_token:
self._ensure_authenticated()
if not self.csrf_token:
raise RuntimeError("Kein CSRF-Token verfügbar")
return {'X-CSRF-Token': self.csrf_token}
def scan_directory(self, directory: Path, recursive: bool = True) -> List[Path]:
"""Scannt Verzeichnis nach unterstützten Bildern"""
@ -305,6 +402,7 @@ class BatchUploader:
def upload_batch(self, images: List[Path],
default_titel: Optional[str] = None,
default_name: Optional[str] = None,
consents: Optional[Dict[str, Any]] = None,
dry_run: bool = False) -> Dict:
"""
Uploaded Bilder gruppiert nach PROJEKTEN (Jahr/Name/Projekt)
@ -317,6 +415,14 @@ class BatchUploader:
if not images:
return {'total': 0, 'successful': 0, 'failed': 0, 'failed_files': []}
consents_payload = consents.copy() if consents else {
'workshopConsent': True,
'socialMediaConsents': []
}
if not consents_payload.get('workshopConsent'):
raise ValueError('workshopConsent ist erforderlich für Batch-Uploads')
# 1. Bilder nach Projekten gruppieren
project_groups = {}
@ -353,6 +459,9 @@ class BatchUploader:
total_failed = 0
failed_files = []
if not dry_run:
self._ensure_authenticated()
for project_key, project_images in project_groups.items():
self.logger.info(f"🚀 Upload Projekt '{project_key}': {len(project_images)} Bilder")
@ -381,12 +490,17 @@ class BatchUploader:
)))
# Ein Upload-Request pro Projekt
payload = {
'metadata': json.dumps(backend_metadata),
'consents': json.dumps(consents_payload)
}
response = self.session.post(
f"{self.backend_url}/upload/batch",
self._api_url('/upload/batch'),
files=files,
data={'metadata': json.dumps(backend_metadata)},
timeout=120,
auth=self.auth
data=payload,
headers=self._authorized_headers(),
timeout=120
)
# Files schließen
@ -544,7 +658,7 @@ class BatchUploader:
def test_connection(self) -> bool:
"""Testet Verbindung zum Backend (mit optionaler Auth)"""
try:
response = self.session.get(f"{self.backend_url}/groups", timeout=10, auth=self.auth)
response = self.session.get(self._api_url('/groups'), timeout=10)
return response.status_code == 200
except Exception as e:
self.logger.error(f"Verbindungstest fehlgeschlagen: {e}")
@ -586,10 +700,10 @@ Beispiele:
default=DEFAULT_BACKEND_URL,
help=f'Backend URL (Standard: {DEFAULT_BACKEND_URL})')
parser.add_argument('--user',
help='HTTP Basic Auth Benutzername (optional)')
parser.add_argument('--user', '--username', dest='username',
help='Admin-Benutzername für Session-Login (erforderlich für Upload)')
parser.add_argument('--password',
help='HTTP Basic Auth Passwort (optional)')
help='Admin-Passwort für Session-Login (erforderlich für Upload)')
parser.add_argument('--no-recursive',
action='store_true',
help='Nicht rekursiv in Unterverzeichnisse')
@ -597,6 +711,17 @@ Beispiele:
parser.add_argument('--dry-run',
action='store_true',
help='Nur Analyse, kein Upload')
consent_group = parser.add_mutually_exclusive_group()
consent_group.add_argument('--workshop-consent', dest='workshop_consent',
action='store_true', default=True,
help='Zustimmung zur Anzeige in der Werkstatt (Standard)')
consent_group.add_argument('--no-workshop-consent', dest='workshop_consent',
action='store_false',
help='Keine Zustimmung zur Anzeige in der Werkstatt')
parser.add_argument('--social-media-consents',
help='JSON (String oder Datei) mit Social-Media-Consents')
parser.add_argument('--verbose', '-v',
action='store_true',
@ -609,9 +734,24 @@ Beispiele:
logger = logging.getLogger(__name__)
try:
try:
social_media_consents = load_social_media_consents(args.social_media_consents)
except ValueError as exc:
logger.error(str(exc))
return 1
# Verzeichnis validieren
directory = Path(args.directory).resolve()
uploader = BatchUploader(args.backend, args.user, args.password)
uploader = BatchUploader(args.backend, args.username, args.password)
consents_config = {
'workshopConsent': args.workshop_consent,
'socialMediaConsents': social_media_consents
}
if not args.dry_run and not uploader.has_credentials():
logger.error("Für Uploads werden Admin-Credentials benötigt (--user / --password)")
return 1
# Verbindung testen (nur bei echtem Upload)
if not args.dry_run:
@ -620,6 +760,8 @@ Beispiele:
logger.error("❌ Backend nicht erreichbar!")
return 1
logger.info("✅ Backend erreichbar")
uploader.ensure_admin_session()
logger.info("✅ Admin-Session aktiv")
else:
logger.info("🔍 Dry-Run Mode - Überspringe Verbindungstest")
@ -651,6 +793,7 @@ Beispiele:
images,
args.titel,
args.name,
consents_config,
args.dry_run
)

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

16
scripts/examples.sh Executable file → Normal file
View File

@ -15,16 +15,11 @@ echo "curl http://localhost:5000/api/groups"
# Beispiel 1: Einfacher Upload
echo -e "\n2. Einfacher Upload:"
echo "python batch_uploader.py ./test_images --titel \"Test Sammlung\""
echo "python batch_uploader.py ./test_images --titel \"Test Sammlung\" --user admin --password 'SehrSicher123!'"
# Beispiel 2: Erweiterte Optionen
echo -e "\n3. Mit allen Optionen:"
echo "python batch_uploader.py /home/user/photos \\"
echo " --titel \"Urlaubsbilder 2024\" \\"
echo " --name \"Max Mustermann\" \\"
echo " --backend http://localhost:5000 \\"
echo " --chunk-size 10 \\"
echo " --verbose"
echo "python batch_uploader.py /home/user/photos --titel \"Urlaubsbilder 2024\" --name \"Max Mustermann\" --backend http://localhost:5000 --user admin --password 'SehrSicher123!' --social-media-consents consents.json --verbose"
# Beispiel 3: Dry Run
echo -e "\n4. Dry Run (Analyse ohne Upload):"
@ -32,10 +27,7 @@ echo "python batch_uploader.py ./images --dry-run --verbose"
# Beispiel 4: Große Sammlung
echo -e "\n5. Große Sammlung optimiert:"
echo "python batch_uploader.py /massive/photo/archive \\"
echo " --titel \"Foto Archiv\" \\"
echo " --chunk-size 3 \\"
echo " --verbose"
echo "python batch_uploader.py /massive/photo/archive --titel \"Foto Archiv\" --user admin --password 'SehrSicher123!' --verbose"
# Test-Verzeichnis erstellen
echo -e "\n6. Test-Verzeichnis erstellen:"
@ -72,4 +64,4 @@ if [ "$1" = "--interactive" ]; then
echo ""
echo "💡 Kopiere jetzt Testbilder in diese Verzeichnisse"
echo "💡 Dann teste mit: python batch_uploader.py test_images --dry-run"
fi
fi

View File

@ -121,15 +121,16 @@ def run_test_commands():
print(f"cd {Path.cwd()}")
print()
credentials = "--user admin --password 'SehrSicher123!'"
commands = [
"# 1. Dry-Run Test (Neue Struktur)",
f"python3 batch_uploader.py {TEST_DIR} --dry-run --verbose",
"",
"# 2. Einzelnes Projekt testen",
f"python3 batch_uploader.py {TEST_DIR}/2024/Max_Mustermann/Urlaub_Mallorca --titel \"Mallorca Test\" --chunk-size 2",
f"python3 batch_uploader.py {TEST_DIR}/2024/Max_Mustermann/Urlaub_Mallorca --titel \"Mallorca Test\" {credentials}",
"",
"# 3. Vollständiger Upload (Neue Struktur)",
f"python3 batch_uploader.py {TEST_DIR} --titel \"Test Sammlung\" --name \"Test User\" --verbose",
f"python3 batch_uploader.py {TEST_DIR} --titel \"Test Sammlung\" --name \"Test User\" --verbose {credentials}",
"",
"# 4. Backend Status prüfen",
"curl http://localhost:5000/api/groups",