From 6332b82c6a367dfef7ede372c9c244805d44fab1 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 23 Nov 2025 21:18:42 +0100 Subject: [PATCH] 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 --- AUTHENTICATION.md | 132 +- CHANGELOG.md | 24 +- FeatureRequests/FEATURE_PLAN-security.md | 109 + FeatureRequests/FEATURE_REQUEST-security.md | 7 + FeatureRequests/FEATURE_TESTPLAN-security.md | 76 + .../done/FEATURE_PLAN-autogen-openapi.md | 4 +- .../done/FEATURE_REQUEST-autogen-openapi.md | 4 +- README.dev.md | 48 +- README.md | 21 +- TODO.md | 16 +- backend/.env.example | 20 - backend/docs/openapi.json | 2354 ++++++++++------- backend/jest.config.js | 1 + backend/package.json | 8 +- backend/src/database/DatabaseManager.js | 84 +- .../migrations/008_create_admin_users.sql | 21 + backend/src/database/schema.sql | 22 + backend/src/middlewares/auth.js | 42 +- backend/src/middlewares/csrf.js | 40 + backend/src/middlewares/index.js | 2 + backend/src/middlewares/session.js | 42 + .../src/repositories/AdminUserRepository.js | 67 + backend/src/routes/README.md | 4 +- backend/src/routes/admin.js | 63 + backend/src/routes/auth.js | 165 ++ backend/src/routes/consent.js | 2 + backend/src/routes/index.js | 4 + backend/src/routes/migration.js | 5 +- backend/src/routes/reorder.js | 5 + backend/src/routes/routeMappings.js | 4 + backend/src/routes/socialMedia.js | 24 + backend/src/scripts/createAdminUser.js | 102 + backend/src/server.js | 58 +- backend/src/services/AdminAuthService.js | 164 ++ backend/tests/api/admin-auth.test.js | 46 +- backend/tests/api/admin.test.js | 6 +- backend/tests/api/consent.test.js | 42 +- backend/tests/env.js | 4 + backend/tests/globalSetup.js | 2 +- backend/tests/setup.js | 9 +- backend/tests/testServer.js | 25 +- backend/tests/unit/auth.test.js | 135 +- .../tests/unit/groupCleanupService.test.js | 153 ++ backend/tests/unit/groupFormatter.test.js | 112 + backend/tests/utils/adminSession.js | 73 + dev.sh | 24 +- docker/dev/docker-compose.yml | 4 +- docker/dev/frontend/Dockerfile | 3 - docker/dev/frontend/nginx.conf | 5 +- docker/prod/docker-compose.yml | 4 +- docker/prod/frontend/Dockerfile | 3 - docker/prod/frontend/nginx.conf | 18 +- frontend/.env.example | 11 +- frontend/MIGRATION-GUIDE.md | 226 +- frontend/package.json | 2 +- frontend/src/App.css | 9 + frontend/src/App.js | 11 +- .../Components/AdminAuth/AdminLoginForm.jsx | 81 + .../Components/AdminAuth/AdminSessionGate.jsx | 57 + .../AdminAuth/ForcePasswordChangeForm.jsx | 119 + .../AdminAuth/InitialAdminSetupForm.jsx | 109 + .../MultiUpload/ConsentCheckboxes.js | 7 +- .../Pages/ModerationGroupImagesPage.js | 96 +- .../Components/Pages/ModerationGroupsPage.js | 400 +-- frontend/src/contexts/AdminSessionContext.jsx | 98 + frontend/src/services/adminApi.js | 248 +- frontend/src/services/adminErrorHandler.js | 68 +- frontend/src/services/socialMediaApi.js | 21 + scripts/README.md | 91 +- scripts/batch_uploader.py | 195 +- scripts/create_admin_user.sh | 280 ++ scripts/examples.sh | 16 +- scripts/test_setup.py | 5 +- 73 files changed, 4725 insertions(+), 1837 deletions(-) create mode 100644 FeatureRequests/FEATURE_PLAN-security.md create mode 100644 FeatureRequests/FEATURE_TESTPLAN-security.md delete mode 100644 backend/.env.example create mode 100644 backend/src/database/migrations/008_create_admin_users.sql create mode 100644 backend/src/middlewares/csrf.js create mode 100644 backend/src/middlewares/session.js create mode 100644 backend/src/repositories/AdminUserRepository.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/socialMedia.js create mode 100644 backend/src/scripts/createAdminUser.js create mode 100644 backend/src/services/AdminAuthService.js create mode 100644 backend/tests/env.js create mode 100644 backend/tests/unit/groupCleanupService.test.js create mode 100644 backend/tests/unit/groupFormatter.test.js create mode 100644 backend/tests/utils/adminSession.js create mode 100644 frontend/src/Components/AdminAuth/AdminLoginForm.jsx create mode 100644 frontend/src/Components/AdminAuth/AdminSessionGate.jsx create mode 100644 frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx create mode 100644 frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx create mode 100644 frontend/src/contexts/AdminSessionContext.jsx create mode 100644 frontend/src/services/socialMediaApi.js create mode 100755 scripts/create_admin_user.sh mode change 100755 => 100644 scripts/examples.sh diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 7a60e73..e2983cf 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 716e9d9..e1fe2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [Unreleased] - Branch: feature/security + +### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025) + +#### Backend +- ✅ **Server-Side Sessions + CSRF**: Replaced Bearer-token auth with HttpOnly session cookies backed by SQLite, added `requireAdminAuth` + `requireCsrf` middlewares, and exposed `GET /auth/csrf-token` for clients. +- ✅ **New Auth Lifecycle**: Added `GET /auth/setup/status`, `POST /auth/setup/initial-admin`, `POST /auth/login`, `POST /auth/logout`, `POST /auth/change-password`, and `POST /api/admin/users` to support onboarding, login, rotation, and creating additional admins. +- ✅ **Admin Directory**: Introduced `admin_users` table, repository, and `AdminAuthService` (hash/verify, forced password change flag, audit-friendly responses) plus Jest coverage for the new flows. +- ✅ **OpenAPI & Swagger Stability**: Regenerate spec on dev start only, ignore `docs/openapi.json` in nodemon watches, and expose Swagger UI reliably at `http://localhost:5001/api/docs/`. + +#### Frontend +- ✅ **Admin Session Context**: New `AdminSessionProvider` manages setup/login state, CSRF persistence, and guards moderation routes via `AdminSessionGate`. +- ✅ **Force Password Change UX**: Added `ForcePasswordChangeForm`, change-password API helper, and conditional gate that blocks moderation access until the first login password is rotated. +- ✅ **Management UI Updates**: Moderation/management pages now assume cookie-based auth, automatically attach CSRF headers, and gracefully handle session expiry. + +#### Tooling & Scripts +- ✅ **API-Driven CLI**: Replaced the legacy Node-only helper with `scripts/create_admin_user.sh`, which can bootstrap the first admin or log in via API to add additional admins from any Linux machine. +- ✅ **Docker & Docs Alignment**: Updated dev/prod compose files, Nginx configs, and `README*`/`AUTHENTICATION.md`/`frontend/MIGRATION-GUIDE.md` to describe the new security model and CLI workflow. +- ✅ **Feature Documentation**: Added `FeatureRequests/FEATURE_PLAN-security.md` + `FEATURE_TESTPLAN-security.md` outlining design, validation steps, and residual follow-ups. + +--- + ## [Unreleased] - Branch: feature/SocialMedia ### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025) @@ -56,7 +78,7 @@ - ✅ **OpenAPI Auto-Generation**: - Automatic spec generation on backend start (dev mode) - - Swagger UI available at `/api/docs` in development + - Swagger UI available at `/api/docs/` in development - Skip generation in test and production modes #### Bug Fixes diff --git a/FeatureRequests/FEATURE_PLAN-security.md b/FeatureRequests/FEATURE_PLAN-security.md new file mode 100644 index 0000000..e465a09 --- /dev/null +++ b/FeatureRequests/FEATURE_PLAN-security.md @@ -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. diff --git a/FeatureRequests/FEATURE_REQUEST-security.md b/FeatureRequests/FEATURE_REQUEST-security.md index 85141ad..123c907 100644 --- a/FeatureRequests/FEATURE_REQUEST-security.md +++ b/FeatureRequests/FEATURE_REQUEST-security.md @@ -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) diff --git a/FeatureRequests/FEATURE_TESTPLAN-security.md b/FeatureRequests/FEATURE_TESTPLAN-security.md new file mode 100644 index 0000000..fe498cf --- /dev/null +++ b/FeatureRequests/FEATURE_TESTPLAN-security.md @@ -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. diff --git a/FeatureRequests/done/FEATURE_PLAN-autogen-openapi.md b/FeatureRequests/done/FEATURE_PLAN-autogen-openapi.md index d91a192..7bc906e 100644 --- a/FeatureRequests/done/FEATURE_PLAN-autogen-openapi.md +++ b/FeatureRequests/done/FEATURE_PLAN-autogen-openapi.md @@ -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/ ``` --- diff --git a/FeatureRequests/done/FEATURE_REQUEST-autogen-openapi.md b/FeatureRequests/done/FEATURE_REQUEST-autogen-openapi.md index 1624bd8..c329e7a 100644 --- a/FeatureRequests/done/FEATURE_REQUEST-autogen-openapi.md +++ b/FeatureRequests/done/FEATURE_REQUEST-autogen-openapi.md @@ -21,7 +21,7 @@ ## Anforderungen an das Feature 1. Beim lokalen Dev‑Start soll eine OpenAPI Spec erzeugt werden (z. B. mit `swagger-autogen` oder `express-oas-generator`). -2. Eine Swagger UI (nur in Dev) soll unter `/api/docs` erreichbar sein und die erzeugte Spec anzeigen. +2. Eine Swagger UI (nur in Dev) soll unter `/api/docs/` erreichbar sein und die erzeugte Spec anzeigen. 3. Automatisch erkannte Endpunkte müssen sichtbar sein; für komplexe Fälle (multipart Uploads) sollen einfache Hints / Overrides möglich sein. 4. Keine Breaking Changes am Produktions‑Startverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per opt‑in env var. 5. Erzeugte Spec soll ins Repo (z. B. `docs/openapi.json`) optional geschrieben werden können (für CI/Review). @@ -30,7 +30,7 @@ ## Minimaler Scope (MVP) - Dev‑only Integration: Generator installiert und beim Start einmal ausgeführt. -- Swagger UI unter `/api/docs` mit generierter Spec. +- Swagger UI unter `/api/docs/` mit generierter Spec. - Kurze Anleitung im `README.dev.md` wie man die Doku lokal öffnet. --- diff --git a/README.dev.md b/README.dev.md index a33e303..f6f3949 100644 --- a/README.dev.md +++ b/README.dev.md @@ -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 diff --git a/README.md b/README.md index cfd7b72..e5ff297 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,13 @@ This project extends the original [Image-Uploader by vallezw](https://github.com - Test execution time: ~10 seconds for full suite - CI/CD ready with proper teardown and cleanup -- **🔒 Admin API Authentication** (Nov 16): - - Bearer token authentication for all admin endpoints - - Secure ADMIN_API_KEY environment variable configuration +- **🔒 Admin Session Authentication** (Nov 16): + - Server-managed HTTP sessions for all admin/system endpoints + - CSRF protection on every mutating request via `X-CSRF-Token` + - Secure `ADMIN_SESSION_SECRET` configuration keeps cookies tamper-proof - Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback` - - 403 Forbidden responses for missing/invalid tokens + - Session-aware moderation UI with login + first-admin setup wizard - Complete authentication documentation in `AUTHENTICATION.md` - - Ready for production deployment with token rotation support - **📋 API Route Documentation** (Nov 16): - Single Source of Truth: `backend/src/routes/routeMappings.js` @@ -206,11 +206,12 @@ The application automatically generates optimized preview thumbnails for all upl ### Moderation Interface (Protected) -- **Access**: `http://localhost/moderation` (requires authentication) -- **Authentication Methods**: - - **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup) - - **API Direct Access**: Bearer Token via `Authorization: Bearer ` 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 diff --git a/TODO.md b/TODO.md index d0be951..48563bf 100644 --- a/TODO.md +++ b/TODO.md @@ -66,7 +66,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images [x] In der angezeigten Gruppen soll statt Bilder ansehen Gruppe editieren stehen [x] Diese bestehende Ansicht (Bilder ansehen) soll als eigene Seite implementiert werden [x] Ergänzung der Möglichkeit eine Beschreibung zu den Bildern hinzuzufügen -[ ] Erweiterung der ModerationPage um reine Datenbankeditor der sqlite Datenbank. + ## 🚀 Deployment-Überlegungen @@ -98,16 +98,16 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images - ✅ Mobile-Kompatibilität ### Nice-to-Have -- 🎨 Drag & Drop Reihenfolge ändern -- 📊 Upload-Progress mit Details -- 🖼️ Thumbnail-Navigation in Slideshow -- 🔄 Batch-Operations (alle entfernen, etc.) +[x] 🎨 Drag & Drop Reihenfolge ändern +[x] 📊 Upload-Progress mit Details +[x] 🖼️ Thumbnail-Navigation in Slideshow +[ ] 🔄 Batch-Operations (alle entfernen, etc.) ### Future Features - 👤 User-Management -- 🏷️ Tagging-System -- 📤 Export-Funktionen -- 🎵 Audio-Integration + + + --- diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 057eca2..0000000 --- a/backend/.env.example +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/docs/openapi.json b/backend/docs/openapi.json index 6725bd1..8bf480b 100644 --- a/backend/docs/openapi.json +++ b/backend/docs/openapi.json @@ -18,6 +18,12 @@ { "name": "Management Portal" }, + { + "name": "Consent Management" + }, + { + "name": "Admin - Users" + }, { "name": "Admin - Deletion Log" }, @@ -30,14 +36,178 @@ { "name": "Admin - Groups Moderation" }, - { - "name": "Consent Management" - }, { "name": "System Migration" } ], "paths": { + "/auth/setup/status": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/auth/setup/initial-admin": { + "post": { + "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "username": { + "example": "any" + }, + "password": { + "example": "any" + } + } + } + } + ], + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request" + }, + "409": { + "description": "Conflict" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/auth/login": { + "post": { + "description": "", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "username": { + "example": "any" + }, + "password": { + "example": "any" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "409": { + "description": "Conflict" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/auth/logout": { + "post": { + "description": "", + "responses": { + "204": { + "description": "No Content" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/auth/csrf-token": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden" + } + } + } + }, + "/auth/change-password": { + "post": { + "description": "", + "parameters": [ + { + "name": "x-csrf-token", + "in": "header", + "type": "string" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "currentPassword": { + "example": "any" + }, + "newPassword": { + "example": "any" + } + } + } + } + ], + "responses": { + "0": { + "description": "" + }, + "4": { + "description": "" + }, + "5": { + "description": "" + }, + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/upload": { "post": { "tags": [ @@ -219,6 +389,19 @@ } } }, + "/api/social-media/platforms": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/manage/{token}": { "get": { "tags": [ @@ -823,996 +1006,6 @@ } } }, - "/api/admin/deletion-log": { - "get": { - "tags": [ - "Admin - Deletion Log" - ], - "summary": "Get recent deletion log entries", - "description": "Returns recent deletion log entries with optional limit", - "parameters": [ - { - "name": "limit", - "in": "query", - "type": "integer", - "description": "Number of entries to return (1-1000)", - "example": 10 - } - ], - "responses": { - "200": { - "description": "Deletion log entries", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "deletions": { - "type": "array", - "example": [], - "items": {} - }, - "total": { - "type": "number", - "example": 2 - }, - "limit": { - "type": "number", - "example": 10 - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Invalid limit parameter" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/deletion-log/all": { - "get": { - "tags": [ - "Admin - Deletion Log" - ], - "summary": "Get all deletion log entries", - "description": "Returns complete deletion log without pagination", - "responses": { - "200": { - "description": "All deletion log entries", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "deletions": { - "type": "array", - "example": [], - "items": {} - }, - "total": { - "type": "number", - "example": 50 - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/deletion-log/stats": { - "get": { - "tags": [ - "Admin - Deletion Log" - ], - "summary": "Get deletion statistics", - "description": "Returns aggregated statistics about deleted images", - "responses": { - "200": { - "description": "Deletion statistics", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "totalDeleted": { - "type": "number", - "example": 12 - }, - "totalImages": { - "type": "number", - "example": 348 - }, - "totalSize": { - "type": "string", - "example": "19.38 MB" - }, - "totalSizeBytes": { - "type": "number", - "example": 20324352 - }, - "lastCleanup": { - "type": "string", - "example": "2025-11-15T10:30:00Z" - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/cleanup/trigger": { - "post": { - "tags": [ - "Admin - Cleanup" - ], - "summary": "Manually trigger cleanup of unapproved groups", - "description": "Deletes groups that have not been approved within retention period", - "responses": { - "200": { - "description": "Cleanup completed", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "deletedGroups": { - "type": "number", - "example": 3 - }, - "message": { - "type": "string", - "example": "3 alte unbestätigte Gruppen gelöscht" - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/cleanup/preview": { - "get": { - "tags": [ - "Admin - Cleanup" - ], - "summary": "Preview groups that would be deleted", - "description": "Dry-run showing which unapproved groups are eligible for deletion", - "responses": { - "200": { - "description": "Preview of groups to delete", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "groupsToDelete": { - "type": "number", - "example": 2 - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "abc123" - }, - "groupName": { - "type": "string", - "example": "Familie_Mueller" - }, - "uploadDate": { - "type": "string", - "example": "2025-10-01T12:00:00Z" - }, - "daysUntilDeletion": { - "type": "number", - "example": -5 - }, - "imageCount": { - "type": "number", - "example": 8 - } - } - } - }, - "message": { - "type": "string", - "example": "2 groups would be deleted" - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/rate-limiter/stats": { - "get": { - "tags": [ - "Admin - Monitoring" - ], - "summary": "Get rate limiter statistics", - "description": "Returns statistics about rate limiting (blocked requests, active limits)", - "responses": { - "200": { - "description": "Rate limiter statistics", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "totalRequests": { - "type": "number", - "example": 1523 - }, - "blockedRequests": { - "type": "number", - "example": 12 - }, - "activeClients": { - "type": "number", - "example": 45 - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/management-audit": { - "get": { - "tags": [ - "Admin - Monitoring" - ], - "summary": "Get management audit log entries", - "description": "Returns recent management portal activity logs", - "parameters": [ - { - "name": "limit", - "in": "query", - "type": "integer", - "description": "Number of entries to return (1-1000)", - "example": 100 - } - ], - "responses": { - "200": { - "description": "Audit log entries", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "logs": { - "type": "array", - "example": [], - "items": {} - }, - "total": { - "type": "number", - "example": 15 - }, - "limit": { - "type": "number", - "example": 100 - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Invalid limit parameter" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/management-audit/stats": { - "get": { - "tags": [ - "Admin - Monitoring" - ], - "summary": "Get management audit log statistics", - "description": "Returns aggregated statistics about management portal activity", - "responses": { - "200": { - "description": "Audit log statistics", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "totalActions": { - "type": "number", - "example": 523 - }, - "actionsByType": { - "type": "object", - "properties": { - "update": { - "type": "number", - "example": 312 - }, - "delete": { - "type": "number", - "example": 45 - }, - "approve": { - "type": "number", - "example": 166 - } - } - }, - "lastAction": { - "type": "string", - "example": "2025-11-15T14:30:00Z" - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/management-audit/group/{groupId}": { - "get": { - "tags": [ - "Admin - Monitoring" - ], - "summary": "Get audit log for specific group", - "description": "Returns all management actions performed on a specific group", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - } - ], - "responses": { - "200": { - "description": "Audit log for group", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "groupId": { - "type": "string", - "example": "abc123def456" - }, - "logs": { - "type": "array", - "example": [], - "items": {} - }, - "total": { - "type": "number", - "example": 8 - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/groups": { - "get": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Get all groups for moderation", - "description": "Returns all groups including unapproved ones with moderation info and consent data", - "parameters": [ - { - "name": "workshopOnly", - "in": "query", - "type": "boolean", - "description": "Filter by workshop consent", - "example": false - }, - { - "name": "platform", - "in": "query", - "type": "string", - "description": "Filter by social media platform", - "example": "instagram" - } - ], - "responses": { - "200": { - "description": "All groups with moderation info", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "groupId": { - "type": "string", - "example": "abc123" - }, - "groupName": { - "type": "string", - "example": "Familie_Mueller" - }, - "isApproved": { - "type": "boolean", - "example": false - }, - "uploadDate": { - "type": "string", - "example": "2025-11-01T10:00:00Z" - }, - "imageCount": { - "type": "number", - "example": 12 - }, - "socialMediaConsents": { - "type": "array", - "example": [], - "items": {} - } - } - } - } - }, - "xml": { - "name": "main" - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/groups/{groupId}": { - "get": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Get single group for moderation", - "description": "Returns detailed info for a specific group including unapproved ones", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - } - ], - "responses": { - "200": { - "description": "Group details with images", - "schema": { - "type": "object", - "properties": { - "groupId": { - "type": "string", - "example": "abc123" - }, - "groupName": { - "type": "string", - "example": "Familie_Mueller" - }, - "isApproved": { - "type": "boolean", - "example": true - }, - "images": { - "type": "array", - "example": [], - "items": {} - } - }, - "xml": { - "name": "main" - } - } - }, - "404": { - "description": "Group not found" - }, - "500": { - "description": "Internal Server Error" - } - } - }, - "patch": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Update group metadata", - "description": "Updates group metadata fields (year, title, description, name)", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "year": { - "type": "number", - "example": 2025 - }, - "title": { - "type": "string", - "example": "Sommercamp" - }, - "description": { - "type": "string", - "example": "Tolle Veranstaltung" - }, - "name": { - "type": "string", - "example": "Familie_Mueller" - } - } - } - } - ], - "responses": { - "200": { - "description": "Group updated successfully", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Gruppe aktualisiert" - }, - "updatedFields": { - "type": "array", - "example": [ - "year", - "title" - ], - "items": { - "type": "string" - } - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Group not found" - }, - "500": { - "description": "Internal Server Error" - } - } - }, - "delete": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Delete a group", - "description": "Deletes a complete group including all images and metadata", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - } - ], - "responses": { - "200": { - "description": "Group deleted successfully", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Gruppe erfolgreich gelöscht" - }, - "groupId": { - "type": "string", - "example": "abc123def456" - } - }, - "xml": { - "name": "main" - } - } - }, - "404": { - "description": "Group not found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/groups/{groupId}/approve": { - "patch": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Approve a group", - "description": "Marks a group as approved, making it publicly visible", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - }, - { - "name": "body", - "in": "body", - "required": false, - "schema": { - "type": "object", - "properties": { - "approved": { - "type": "boolean", - "example": true - } - } - } - } - ], - "responses": { - "200": { - "description": "Group approved successfully", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Gruppe erfolgreich freigegeben" - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Group not found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/groups/{groupId}/images/{imageId}": { - "delete": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Delete a single image", - "description": "Deletes a specific image from a group", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - }, - { - "name": "imageId", - "in": "path", - "required": true, - "type": "integer", - "description": "Image ID", - "example": 42 - } - ], - "responses": { - "200": { - "description": "Image deleted successfully", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Bild erfolgreich gelöscht" - }, - "groupId": { - "type": "string", - "example": "abc123def456" - }, - "imageId": { - "type": "number", - "example": 42 - } - }, - "xml": { - "name": "main" - } - } - }, - "404": { - "description": "Image not found" - }, - "500": { - "description": "Internal Server Error" - } - } - }, - "patch": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Update single image description", - "description": "Updates description for a specific image (max 200 characters)", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - }, - { - "name": "imageId", - "in": "path", - "required": true, - "type": "integer", - "description": "Image ID", - "example": 42 - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "image_description": { - "type": "string", - "example": "Sonnenuntergang am Strand" - } - } - } - } - ], - "responses": { - "200": { - "description": "Description updated", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Bildbeschreibung erfolgreich aktualisiert" - }, - "groupId": { - "type": "string", - "example": "abc123def456" - }, - "imageId": { - "type": "number", - "example": 42 - }, - "imageDescription": { - "type": "string", - "example": "Sonnenuntergang am Strand" - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Description too long (max 200 chars)" - }, - "404": { - "description": "Image not found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/admin/groups/{groupId}/images/batch-description": { - "patch": { - "tags": [ - "Admin - Groups Moderation" - ], - "summary": "Batch update image descriptions", - "description": "Updates descriptions for multiple images in a group at once", - "parameters": [ - { - "name": "groupId", - "in": "path", - "required": true, - "type": "string", - "description": "Group ID", - "example": "abc123def456" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "descriptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "imageId": { - "type": "number", - "example": 2 - }, - "description": { - "type": "string", - "example": "Gruppenfoto beim Lagerfeuer" - } - } - } - } - } - } - } - ], - "responses": { - "200": { - "description": "Descriptions updated", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "updatedCount": { - "type": "number", - "example": 2 - }, - "message": { - "type": "string", - "example": "2 Bildbeschreibungen aktualisiert" - } - }, - "xml": { - "name": "main" - } - } - }, - "400": { - "description": "Invalid request format" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, "/api/admin/social-media/platforms": { "get": { "tags": [ @@ -1855,6 +1048,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error" } @@ -1894,6 +1090,9 @@ "400": { "description": "Bad Request" }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Not Found" }, @@ -1970,6 +1169,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Group not found" }, @@ -2042,6 +1244,9 @@ "400": { "description": "Invalid platformId" }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error" } @@ -2130,6 +1335,1146 @@ "400": { "description": "Invalid format" }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/users": { + "post": { + "tags": [ + "Admin - Users" + ], + "summary": "Create a new admin user", + "description": "Adds an additional admin (or auditor) via API", + "responses": { + "201": { + "description": "Admin user created", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 5 + }, + "username": { + "type": "string", + "example": "admin2" + }, + "role": { + "type": "string", + "example": "admin" + }, + "requiresPasswordChange": { + "type": "boolean", + "example": false + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Conflict" + }, + "500": { + "description": "Internal Server Error" + } + }, + "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 + } + } + } + } + } + } + } + }, + "/api/admin/deletion-log": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get recent deletion log entries", + "description": "Returns recent deletion log entries with optional limit", + "parameters": [ + { + "name": "limit", + "in": "query", + "type": "integer", + "description": "Number of entries to return (1-1000)", + "example": 10 + } + ], + "responses": { + "200": { + "description": "Deletion log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletions": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 2 + }, + "limit": { + "type": "number", + "example": 10 + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid limit parameter" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/deletion-log/all": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get all deletion log entries", + "description": "Returns complete deletion log without pagination", + "responses": { + "200": { + "description": "All deletion log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletions": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 50 + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/deletion-log/stats": { + "get": { + "tags": [ + "Admin - Deletion Log" + ], + "summary": "Get deletion statistics", + "description": "Returns aggregated statistics about deleted images", + "responses": { + "200": { + "description": "Deletion statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalDeleted": { + "type": "number", + "example": 12 + }, + "totalImages": { + "type": "number", + "example": 348 + }, + "totalSize": { + "type": "string", + "example": "19.38 MB" + }, + "totalSizeBytes": { + "type": "number", + "example": 20324352 + }, + "lastCleanup": { + "type": "string", + "example": "2025-11-15T10:30:00Z" + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/cleanup/trigger": { + "post": { + "tags": [ + "Admin - Cleanup" + ], + "summary": "Manually trigger cleanup of unapproved groups", + "description": "Deletes groups that have not been approved within retention period", + "responses": { + "200": { + "description": "Cleanup completed", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "deletedGroups": { + "type": "number", + "example": 3 + }, + "message": { + "type": "string", + "example": "3 alte unbestätigte Gruppen gelöscht" + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/cleanup/preview": { + "get": { + "tags": [ + "Admin - Cleanup" + ], + "summary": "Preview groups that would be deleted", + "description": "Dry-run showing which unapproved groups are eligible for deletion", + "responses": { + "200": { + "description": "Preview of groups to delete", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groupsToDelete": { + "type": "number", + "example": 2 + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "uploadDate": { + "type": "string", + "example": "2025-10-01T12:00:00Z" + }, + "daysUntilDeletion": { + "type": "number", + "example": -5 + }, + "imageCount": { + "type": "number", + "example": 8 + } + } + } + }, + "message": { + "type": "string", + "example": "2 groups would be deleted" + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/rate-limiter/stats": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get rate limiter statistics", + "description": "Returns statistics about rate limiting (blocked requests, active limits)", + "responses": { + "200": { + "description": "Rate limiter statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalRequests": { + "type": "number", + "example": 1523 + }, + "blockedRequests": { + "type": "number", + "example": 12 + }, + "activeClients": { + "type": "number", + "example": 45 + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get management audit log entries", + "description": "Returns recent management portal activity logs", + "parameters": [ + { + "name": "limit", + "in": "query", + "type": "integer", + "description": "Number of entries to return (1-1000)", + "example": 100 + } + ], + "responses": { + "200": { + "description": "Audit log entries", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "logs": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 15 + }, + "limit": { + "type": "number", + "example": 100 + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid limit parameter" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit/stats": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get management audit log statistics", + "description": "Returns aggregated statistics about management portal activity", + "responses": { + "200": { + "description": "Audit log statistics", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "totalActions": { + "type": "number", + "example": 523 + }, + "actionsByType": { + "type": "object", + "properties": { + "update": { + "type": "number", + "example": 312 + }, + "delete": { + "type": "number", + "example": 45 + }, + "approve": { + "type": "number", + "example": 166 + } + } + }, + "lastAction": { + "type": "string", + "example": "2025-11-15T14:30:00Z" + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/management-audit/group/{groupId}": { + "get": { + "tags": [ + "Admin - Monitoring" + ], + "summary": "Get audit log for specific group", + "description": "Returns all management actions performed on a specific group", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Audit log for group", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "logs": { + "type": "array", + "example": [], + "items": {} + }, + "total": { + "type": "number", + "example": 8 + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups": { + "get": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Get all groups for moderation", + "description": "Returns all groups including unapproved ones with moderation info and consent data", + "parameters": [ + { + "name": "consents", + "in": "query", + "type": "string" + }, + { + "name": "workshopOnly", + "in": "query", + "type": "boolean", + "description": "Filter by workshop consent", + "example": false + }, + { + "name": "platform", + "in": "query", + "type": "string", + "description": "Filter by social media platform", + "example": "instagram" + } + ], + "responses": { + "200": { + "description": "All groups with moderation info", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "isApproved": { + "type": "boolean", + "example": false + }, + "uploadDate": { + "type": "string", + "example": "2025-11-01T10:00:00Z" + }, + "imageCount": { + "type": "number", + "example": 12 + }, + "socialMediaConsents": { + "type": "array", + "example": [], + "items": {} + } + } + } + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}": { + "get": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Get single group for moderation", + "description": "Returns detailed info for a specific group including unapproved ones", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Group details with images", + "schema": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "example": "abc123" + }, + "groupName": { + "type": "string", + "example": "Familie_Mueller" + }, + "isApproved": { + "type": "boolean", + "example": true + }, + "images": { + "type": "array", + "example": [], + "items": {} + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Update group metadata", + "description": "Updates group metadata fields (year, title, description, name)", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "year": { + "type": "number", + "example": 2025 + }, + "title": { + "type": "string", + "example": "Sommercamp" + }, + "description": { + "type": "string", + "example": "Tolle Veranstaltung" + }, + "name": { + "type": "string", + "example": "Familie_Mueller" + } + } + } + } + ], + "responses": { + "200": { + "description": "Group updated successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe aktualisiert" + }, + "updatedFields": { + "type": "array", + "example": [ + "year", + "title" + ], + "items": { + "type": "string" + } + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "delete": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Delete a group", + "description": "Deletes a complete group including all images and metadata", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + } + ], + "responses": { + "200": { + "description": "Group deleted successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe erfolgreich gelöscht" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/approve": { + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Approve a group", + "description": "Marks a group as approved, making it publicly visible", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": false, + "schema": { + "type": "object", + "properties": { + "approved": { + "type": "boolean", + "example": true + } + } + } + } + ], + "responses": { + "200": { + "description": "Group approved successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Gruppe erfolgreich freigegeben" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Group not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/images/{imageId}": { + "delete": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Delete a single image", + "description": "Deletes a specific image from a group", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "imageId", + "in": "path", + "required": true, + "type": "integer", + "description": "Image ID", + "example": 42 + } + ], + "responses": { + "200": { + "description": "Image deleted successfully", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Bild erfolgreich gelöscht" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "imageId": { + "type": "number", + "example": 42 + } + }, + "xml": { + "name": "main" + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Image not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Update single image description", + "description": "Updates description for a specific image (max 200 characters)", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "imageId", + "in": "path", + "required": true, + "type": "integer", + "description": "Image ID", + "example": 42 + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "image_description": { + "type": "string", + "example": "Sonnenuntergang am Strand" + } + } + } + } + ], + "responses": { + "200": { + "description": "Description updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Bildbeschreibung erfolgreich aktualisiert" + }, + "groupId": { + "type": "string", + "example": "abc123def456" + }, + "imageId": { + "type": "number", + "example": 42 + }, + "imageDescription": { + "type": "string", + "example": "Sonnenuntergang am Strand" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Description too long (max 200 chars)" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Image not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/admin/groups/{groupId}/images/batch-description": { + "patch": { + "tags": [ + "Admin - Groups Moderation" + ], + "summary": "Batch update image descriptions", + "description": "Updates descriptions for multiple images in a group at once", + "parameters": [ + { + "name": "groupId", + "in": "path", + "required": true, + "type": "string", + "description": "Group ID", + "example": "abc123def456" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "descriptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "imageId": { + "type": "number", + "example": 2 + }, + "description": { + "type": "string", + "example": "Gruppenfoto beim Lagerfeuer" + } + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "Descriptions updated", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "updatedCount": { + "type": "number", + "example": 2 + }, + "message": { + "type": "string", + "example": "2 Bildbeschreibungen aktualisiert" + } + }, + "xml": { + "name": "main" + } + } + }, + "400": { + "description": "Invalid request format" + }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Internal Server Error" } @@ -2166,6 +2511,9 @@ "400": { "description": "Bad Request" }, + "403": { + "description": "Forbidden" + }, "404": { "description": "Not Found" }, @@ -2223,6 +2571,13 @@ ], "summary": "Manually trigger migration", "description": "Triggers manual migration from JSON to SQLite database", + "parameters": [ + { + "name": "x-csrf-token", + "in": "header", + "type": "string" + } + ], "responses": { "200": { "description": "Migration successful", @@ -2251,6 +2606,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Migration failed" } @@ -2264,6 +2622,13 @@ ], "summary": "Rollback to JSON", "description": "Emergency rollback from SQLite to JSON file storage", + "parameters": [ + { + "name": "x-csrf-token", + "in": "header", + "type": "string" + } + ], "responses": { "200": { "description": "Rollback successful", @@ -2288,6 +2653,9 @@ } } }, + "403": { + "description": "Forbidden" + }, "500": { "description": "Rollback failed" } diff --git a/backend/jest.config.js b/backend/jest.config.js index 90a6014..b1e0f15 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,6 +1,7 @@ module.exports = { testEnvironment: 'node', coverageDirectory: 'coverage', + setupFiles: ['/tests/env.js'], collectCoverageFrom: [ 'src/**/*.js', '!src/index.js', // Server startup diff --git a/backend/package.json b/backend/package.json index a773bf7..b487d4a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/database/DatabaseManager.js b/backend/src/database/DatabaseManager.js index e403dcd..7993994 100644 --- a/backend/src/database/DatabaseManager.js +++ b/backend/src/database/DatabaseManager.js @@ -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( diff --git a/backend/src/database/migrations/008_create_admin_users.sql b/backend/src/database/migrations/008_create_admin_users.sql new file mode 100644 index 0000000..4a3ad0e --- /dev/null +++ b/backend/src/database/migrations/008_create_admin_users.sql @@ -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; diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index efd03f5..850461a 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -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; \ No newline at end of file diff --git a/backend/src/middlewares/auth.js b/backend/src/middlewares/auth.js index 548cddf..4065bda 100644 --- a/backend/src/middlewares/auth.js +++ b/backend/src/middlewares/auth.js @@ -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 ' - }); - } - - 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(); }; diff --git a/backend/src/middlewares/csrf.js b/backend/src/middlewares/csrf.js new file mode 100644 index 0000000..0250571 --- /dev/null +++ b/backend/src/middlewares/csrf.js @@ -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 }; diff --git a/backend/src/middlewares/index.js b/backend/src/middlewares/index.js index 275c76d..a82e7c1 100644 --- a/backend/src/middlewares/index.js +++ b/backend/src/middlewares/index.js @@ -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()); }; diff --git a/backend/src/middlewares/session.js b/backend/src/middlewares/session.js new file mode 100644 index 0000000..8d1b652 --- /dev/null +++ b/backend/src/middlewares/session.js @@ -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; \ No newline at end of file diff --git a/backend/src/repositories/AdminUserRepository.js b/backend/src/repositories/AdminUserRepository.js new file mode 100644 index 0000000..c86be1f --- /dev/null +++ b/backend/src/repositories/AdminUserRepository.js @@ -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(); diff --git a/backend/src/routes/README.md b/backend/src/routes/README.md index 7d0651e..c358a95 100644 --- a/backend/src/routes/README.md +++ b/backend/src/routes/README.md @@ -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! diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0c35896..7828336 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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) => { /* diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..432415a --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js index 9445a64..12e46bf 100644 --- a/backend/src/routes/consent.js +++ b/backend/src/routes/consent.js @@ -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 diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 310a437..7c3122a 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -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, diff --git a/backend/src/routes/migration.js b/backend/src/routes/migration.js index a34cc6f..4f36d84 100644 --- a/backend/src/routes/migration.js +++ b/backend/src/routes/migration.js @@ -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' diff --git a/backend/src/routes/reorder.js b/backend/src/routes/reorder.js index 357ec34..0e13a61 100644 --- a/backend/src/routes/reorder.js +++ b/backend/src/routes/reorder.js @@ -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 diff --git a/backend/src/routes/routeMappings.js b/backend/src/routes/routeMappings.js index bc23084..da9fb53 100644 --- a/backend/src/routes/routeMappings.js +++ b/backend/src/routes/routeMappings.js @@ -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' }, diff --git a/backend/src/routes/socialMedia.js b/backend/src/routes/socialMedia.js new file mode 100644 index 0000000..3e8bb75 --- /dev/null +++ b/backend/src/routes/socialMedia.js @@ -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; diff --git a/backend/src/scripts/createAdminUser.js b/backend/src/scripts/createAdminUser.js new file mode 100644 index 0000000..9cd4e11 --- /dev/null +++ b/backend/src/scripts/createAdminUser.js @@ -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 --password [--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); + } + } +})(); diff --git a/backend/src/server.js b/backend/src/server.js index 3141206..91ea314 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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}`); diff --git a/backend/src/services/AdminAuthService.js b/backend/src/services/AdminAuthService.js new file mode 100644 index 0000000..3ac2ae8 --- /dev/null +++ b/backend/src/services/AdminAuthService.js @@ -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(); diff --git a/backend/tests/api/admin-auth.test.js b/backend/tests/api/admin-auth.test.js index 2d47382..5eeb356 100644 --- a/backend/tests/api/admin-auth.test.js +++ b/backend/tests/api/admin-auth.test.js @@ -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(); diff --git a/backend/tests/api/admin.test.js b/backend/tests/api/admin.test.js index 97b59ab..5607921 100644 --- a/backend/tests/api/admin.test.js +++ b/backend/tests/api/admin.test.js @@ -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 diff --git a/backend/tests/api/consent.test.js b/backend/tests/api/consent.test.js index 39a5964..72171ef 100644 --- a/backend/tests/api/consent.test.js +++ b/backend/tests/api/consent.test.js @@ -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'); }); diff --git a/backend/tests/env.js b/backend/tests/env.js new file mode 100644 index 0000000..95284ba --- /dev/null +++ b/backend/tests/env.js @@ -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'; diff --git a/backend/tests/globalSetup.js b/backend/tests/globalSetup.js index c940c0d..7c57aca 100644 --- a/backend/tests/globalSetup.js +++ b/backend/tests/globalSetup.js @@ -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 diff --git a/backend/tests/setup.js b/backend/tests/setup.js index f7bcb6b..b165107 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -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(); diff --git a/backend/tests/testServer.js b/backend/tests/testServer.js index 1afbbee..82e2dbd 100644 --- a/backend/tests/testServer.js +++ b/backend/tests/testServer.js @@ -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 }; diff --git a/backend/tests/unit/auth.test.js b/backend/tests/unit/auth.test.js index 592124a..d0a6314 100644 --- a/backend/tests/unit/auth.test.js +++ b/backend/tests/unit/auth.test.js @@ -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 + }); }); }); diff --git a/backend/tests/unit/groupCleanupService.test.js b/backend/tests/unit/groupCleanupService.test.js new file mode 100644 index 0000000..797cc81 --- /dev/null +++ b/backend/tests/unit/groupCleanupService.test.js @@ -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(); + }); + }); +}); diff --git a/backend/tests/unit/groupFormatter.test.js b/backend/tests/unit/groupFormatter.test.js new file mode 100644 index 0000000..8ec3423 --- /dev/null +++ b/backend/tests/unit/groupFormatter.test.js @@ -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 + }); + }); + }); +}); diff --git a/backend/tests/utils/adminSession.js b/backend/tests/utils/adminSession.js new file mode 100644 index 0000000..4455c1e --- /dev/null +++ b/backend/tests/utils/adminSession.js @@ -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 +}; diff --git a/dev.sh b/dev.sh index cf5de60..f16eb22 100755 --- a/dev.sh +++ b/dev.sh @@ -5,7 +5,7 @@ set -euo pipefail -echo "🚀 Starting Project Image Uploader - Development Environment" +echo "Starting Project Image Uploader - Development Environment" echo " Frontend: http://localhost:3000" echo " Backend: http://localhost:5001" echo "" @@ -18,23 +18,23 @@ if docker compose ps | grep -q "image-uploader-frontend.*Up"; then fi # Start development environment -echo "📦 Starting development containers..." +echo "Starting development containers..." docker compose -f docker/dev/docker-compose.yml up -d echo "" -echo "✅ Development environment started!" +echo "Development environment started!" echo "" -echo "📊 Container Status:" +echo "Container Status:" docker compose -f docker/dev/docker-compose.yml ps echo "" -echo "🔗 Access URLs:" -echo " 📱 Frontend (Development): http://localhost:3000" -echo " 🔧 Backend API (Development): http://localhost:5001" +echo "Access URLs:" +echo " Frontend (Development): http://localhost:3000" +echo " Backend API (Development): http://localhost:5001" echo "" -echo "📝 Useful Commands:" -echo " 📋 Show logs: docker compose -f docker/dev/docker-compose.yml logs -f" -echo " 🛑 Stop: docker compose -f docker/dev/docker-compose.yml down" -echo " 🔄 Restart: docker compose -f docker/dev/docker-compose.yml restart" -echo " 🏗️ Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache" +echo "Useful Commands:" +echo " Show logs: docker compose -f docker/dev/docker-compose.yml logs -f" +echo " Stop: docker compose -f docker/dev/docker-compose.yml down" +echo " Restart: docker compose -f docker/dev/docker-compose.yml restart" +echo " Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache" echo "" \ No newline at end of file diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 3c159eb..585a6c5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -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: diff --git a/docker/dev/frontend/Dockerfile b/docker/dev/frontend/Dockerfile index cea0940..d306915 100644 --- a/docker/dev/frontend/Dockerfile +++ b/docker/dev/frontend/Dockerfile @@ -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. diff --git a/docker/dev/frontend/nginx.conf b/docker/dev/frontend/nginx.conf index d76f530..5da9da7 100644 --- a/docker/dev/frontend/nginx.conf +++ b/docker/dev/frontend/nginx.conf @@ -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; diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index 288083d..f6693cf 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -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: diff --git a/docker/prod/frontend/Dockerfile b/docker/prod/frontend/Dockerfile index 21df38c..bf024fe 100644 --- a/docker/prod/frontend/Dockerfile +++ b/docker/prod/frontend/Dockerfile @@ -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 diff --git a/docker/prod/frontend/nginx.conf b/docker/prod/frontend/nginx.conf index 73175ad..f7022ae 100644 --- a/docker/prod/frontend/nginx.conf +++ b/docker/prod/frontend/nginx.conf @@ -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; diff --git a/frontend/.env.example b/frontend/.env.example index 9862726..825b3ea 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/MIGRATION-GUIDE.md b/frontend/MIGRATION-GUIDE.md index 1e1f6ca..b970d60 100644 --- a/frontend/MIGRATION-GUIDE.md +++ b/frontend/MIGRATION-GUIDE.md @@ -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 ` +**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) ``` --- diff --git a/frontend/package.json b/frontend/package.json index 16ef67a..3b63dea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.css b/frontend/src/App.css index 897286b..16d93fb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; } diff --git a/frontend/src/App.js b/frontend/src/App.js index 5ffcd78..a11a942 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 ( - - + + + } /> } /> } /> @@ -23,8 +25,9 @@ function App() { } /> } /> } /> - - + + + ); } diff --git a/frontend/src/Components/AdminAuth/AdminLoginForm.jsx b/frontend/src/Components/AdminAuth/AdminLoginForm.jsx new file mode 100644 index 0000000..cf29860 --- /dev/null +++ b/frontend/src/Components/AdminAuth/AdminLoginForm.jsx @@ -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 ( + + + + Admin Login + + + Bitte melden Sie sich an, um das Moderations-Dashboard zu öffnen. + + {error && ( + + {error} + + )} +
+ + setUsername(e.target.value)} + autoComplete="username" + required + /> + setPassword(e.target.value)} + autoComplete="current-password" + required + /> + + +
+
+
+ ); +}; + +export default AdminLoginForm; diff --git a/frontend/src/Components/AdminAuth/AdminSessionGate.jsx b/frontend/src/Components/AdminAuth/AdminSessionGate.jsx new file mode 100644 index 0000000..96b9013 --- /dev/null +++ b/frontend/src/Components/AdminAuth/AdminSessionGate.jsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+

Fehler beim Laden des Authentifizierungsstatus.

+

{error.message || 'Bitte später erneut versuchen.'}

+
+
+ ); + } + + if (needsSetup) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ( +
+ +
+ ); + } + + if (user?.requiresPasswordChange) { + return ( +
+ +
+ ); + } + + return <>{children}; +}; + +export default AdminSessionGate; diff --git a/frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx b/frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx new file mode 100644 index 0000000..a4d4af3 --- /dev/null +++ b/frontend/src/Components/AdminAuth/ForcePasswordChangeForm.jsx @@ -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 ( + + + + Passwort aktualisieren + + + Der Benutzer "{user?.username}" muss das initiale Passwort ändern, bevor das Dashboard genutzt werden kann. + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} +
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + required + /> + setNewPassword(e.target.value)} + autoComplete="new-password" + required + /> + setConfirmPassword(e.target.value)} + autoComplete="new-password" + required + /> + + +
+
+
+ ); +}; + +export default ForcePasswordChangeForm; diff --git a/frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx b/frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx new file mode 100644 index 0000000..3548272 --- /dev/null +++ b/frontend/src/Components/AdminAuth/InitialAdminSetupForm.jsx @@ -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 ( + + + + Initialer Admin-Zugang + + + Es wurde noch kein Admin-Benutzer angelegt. Bitte erstellen Sie den ersten Account. + + {error && ( + + {error} + + )} +
+ + setUsername(e.target.value)} + autoComplete="username" + required + /> + setPassword(e.target.value)} + helperText="Mindestens 10 Zeichen" + autoComplete="new-password" + required + /> + setPasswordConfirm(e.target.value)} + autoComplete="new-password" + required + /> + + +
+
+
+ ); +}; + +export default InitialAdminSetupForm; diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js b/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js index 0a51c3d..7972860 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js @@ -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); diff --git a/frontend/src/Components/Pages/ModerationGroupImagesPage.js b/frontend/src/Components/Pages/ModerationGroupImagesPage.js index b81cde0..6402da2 100644 --- a/frontend/src/Components/Pages/ModerationGroupImagesPage.js +++ b/frontend/src/Components/Pages/ModerationGroupImagesPage.js @@ -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 ; - if (error) return
{error}
; - if (!group) return
Gruppe nicht gefunden
; + const renderContent = () => { + if (loading) return ; + if (error) return
{error}
; + if (!group) return
Gruppe nicht gefunden
; + + return ( +
+ + + + {/* Image Descriptions Manager */} + + + {/* Group Metadata Editor */} + + + {/* Back Button */} + + + + + +
+
+ ); + }; return ( -
- - - - {/* Image Descriptions Manager */} - - - {/* Group Metadata Editor */} - - - {/* Back Button */} - - - - - -
-
+ + {renderContent()} + ); }; diff --git a/frontend/src/Components/Pages/ModerationGroupsPage.js b/frontend/src/Components/Pages/ModerationGroupsPage.js index cbed756..fc3994a 100644 --- a/frontend/src/Components/Pages/ModerationGroupsPage.js +++ b/frontend/src/Components/Pages/ModerationGroupsPage.js @@ -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
Lade Gruppen...
; - } + 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
{error}
; - } + const renderContent = () => { + if (loading) { + return
Lade Gruppen...
; + } - const pendingGroups = groups.filter(g => !g.approved); - const approvedGroups = groups.filter(g => g.approved); + if (error) { + return
{error}
; + } + + const pendingGroups = groups.filter(g => !g.approved); + const approvedGroups = groups.filter(g => g.approved); + + return ( +
+ + + Moderation - Interne Verwaltung + + + + + + + + + Moderation + + + {user?.username && ( + + Eingeloggt als {user.username} + + )} + + + + +
+
+ {pendingGroups.length} + Wartend +
+
+ {approvedGroups.length} + Freigegeben +
+
+ {groups.length} + Gesamt +
+
+ + {/* Filter und Export Controls */} + + + + + Consent-Filter + + + setConsentFilters({...consentFilters, workshop: e.target.checked})} + size="small" + /> + } + label="Werkstatt" + /> + setConsentFilters({...consentFilters, facebook: e.target.checked})} + size="small" + /> + } + label="Facebook" + /> + setConsentFilters({...consentFilters, instagram: e.target.checked})} + size="small" + /> + } + label="Instagram" + /> + setConsentFilters({...consentFilters, tiktok: e.target.checked})} + size="small" + /> + } + label="TikTok" + /> + + + + + + + {/* Wartende Gruppen */} +
+ +
+ + {/* Freigegebene Gruppen */} +
+ +
+ + {/* Lösch-Historie */} +
+ +
+ + {/* Bilder-Modal */} + {showImages && selectedGroup && ( + { + setShowImages(false); + setSelectedGroup(null); + }} + onDeleteImage={deleteImage} + /> + )} +
+
+
+ ); + }; return ( -
- - - Moderation - Interne Verwaltung - - - - - - -

Moderation

- -
-
- {pendingGroups.length} - Wartend -
-
- {approvedGroups.length} - Freigegeben -
-
- {groups.length} - Gesamt -
-
- - {/* Filter und Export Controls */} - - - - - Consent-Filter - - - setConsentFilters({...consentFilters, workshop: e.target.checked})} - size="small" - /> - } - label="Werkstatt" - /> - setConsentFilters({...consentFilters, facebook: e.target.checked})} - size="small" - /> - } - label="Facebook" - /> - setConsentFilters({...consentFilters, instagram: e.target.checked})} - size="small" - /> - } - label="Instagram" - /> - setConsentFilters({...consentFilters, tiktok: e.target.checked})} - size="small" - /> - } - label="TikTok" - /> - - - - - - - {/* Wartende Gruppen */} -
- -
- - {/* Freigegebene Gruppen */} -
- -
- - {/* Lösch-Historie */} -
- -
- - {/* Bilder-Modal */} - {showImages && selectedGroup && ( - { - setShowImages(false); - setSelectedGroup(null); - }} - onDeleteImage={deleteImage} - /> - )} -
-
-
+ + {renderContent()} + ); }; diff --git a/frontend/src/contexts/AdminSessionContext.jsx b/frontend/src/contexts/AdminSessionContext.jsx new file mode 100644 index 0000000..174e0ef --- /dev/null +++ b/frontend/src/contexts/AdminSessionContext.jsx @@ -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 ( + + {children} + + ); +}; + +export const useAdminSession = () => { + const context = useContext(AdminSessionContext); + if (!context) { + throw new Error('useAdminSession must be used within AdminSessionProvider'); + } + return context; +}; diff --git a/frontend/src/services/adminApi.js b/frontend/src/services/adminApi.js index ef62edd..10630db 100644 --- a/frontend/src/services/adminApi.js +++ b/frontend/src/services/adminApi.js @@ -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} - */ -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} - */ -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} - */ 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} - */ 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(); +}; diff --git a/frontend/src/services/adminErrorHandler.js b/frontend/src/services/adminErrorHandler.js index 508cfd5..53080be 100644 --- a/frontend/src/services/adminErrorHandler.js +++ b/frontend/src/services/adminErrorHandler.js @@ -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: ` -

Admin-Token fehlt oder ist ungültig.

-

Bitte kontaktieren Sie den Administrator.

-
- - Technische Details:
- - Prüfen Sie die REACT_APP_ADMIN_API_KEY Variable
- - Token muss mit Backend ADMIN_API_KEY übereinstimmen
- - Kontext: ${context} -
- `, - 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' }); }; diff --git a/frontend/src/services/socialMediaApi.js b/frontend/src/services/socialMediaApi.js new file mode 100644 index 0000000..ab4a729 --- /dev/null +++ b/frontend/src/services/socialMediaApi.js @@ -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 || []; +}; diff --git a/scripts/README.md b/scripts/README.md index 6aa4add..65f56fb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 ` 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 ``` \ No newline at end of file diff --git a/scripts/batch_uploader.py b/scripts/batch_uploader.py index 9cfada5..f042e43 100644 --- a/scripts/batch_uploader.py +++ b/scripts/batch_uploader.py @@ -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 ) diff --git a/scripts/create_admin_user.sh b/scripts/create_admin_user.sh new file mode 100755 index 0000000..dc1702c --- /dev/null +++ b/scripts/create_admin_user.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/create_admin_user.sh --username --password [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 Backend-Basis-URL (Standard: http://localhost:5000) + --username Neuer Admin-Benutzername (Pflicht) + --password Neues Admin-Passwort (Pflicht, min. 10 Zeichen) + --role Rolle für den Benutzer (Standard: admin) + --require-password-change Markiert den Benutzer für Passwort-Änderung beim Login + --admin-user Bestehender Admin (für zusätzliche Benutzer erforderlich) + --admin-password 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 < --password [--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 diff --git a/scripts/examples.sh b/scripts/examples.sh old mode 100755 new mode 100644 index 35f6a2e..160e8c2 --- a/scripts/examples.sh +++ b/scripts/examples.sh @@ -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 \ No newline at end of file +fi diff --git a/scripts/test_setup.py b/scripts/test_setup.py index 4ac9d8c..d20ee55 100644 --- a/scripts/test_setup.py +++ b/scripts/test_setup.py @@ -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",