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