Compare commits
No commits in common. "main" and "feature/frontend-api-migration" have entirely different histories.
main
...
feature/fr
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -9,11 +9,6 @@ node_modules/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# Telegram credentials
|
|
||||||
scripts/.env.telegram
|
|
||||||
scripts/node_modules/
|
|
||||||
scripts/package-lock.json
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -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 (Session + CSRF)
|
### 1. Admin-Routes (Bearer Token)
|
||||||
- **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
|
- **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
|
||||||
- **Methode**: HTTP Session (Cookie) + CSRF-Token
|
- **Methode**: Bearer Token im Authorization Header
|
||||||
- **Konfiguration**: `.env` → `ADMIN_SESSION_SECRET` (+ Admin-Benutzer in DB)
|
- **Konfiguration**: `.env` → `ADMIN_API_KEY`
|
||||||
|
|
||||||
### 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,55 +20,36 @@ Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unte
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. **Session Secret setzen**:
|
1. **Sicheren Admin-Key generieren**:
|
||||||
|
```bash
|
||||||
|
# Linux/Mac:
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Oder Node.js:
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **In `.env` eintragen**:
|
||||||
```env
|
```env
|
||||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
ADMIN_API_KEY=dein-generierter-key-hier
|
||||||
```
|
|
||||||
> ℹ️ Standardmäßig setzt der Server in Production HTTPS-Only Cookies (`Secure`). Falls deine Installation **ohne HTTPS** hinter einem internen Netzwerk läuft, kannst du das Verhalten über `ADMIN_SESSION_COOKIE_SECURE=false` explizit deaktivieren. Verwende dies nur in vertrauenswürdigen Umgebungen und setze den Wert vorzugsweise per lokaler Compose-Override-Datei oder geheimen ENV-Variablen, damit das Repo weiterhin den sicheren Default `true` behält.
|
|
||||||
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 `/api/admin/*`- und `/api/system/*`-Routen setzen voraus:
|
Alle Requests an `/api/admin/*` benötigen den Authorization Header:
|
||||||
|
|
||||||
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
|
||||||
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
|
curl -H "Authorization: Bearer dein-generierter-key-hier" \
|
||||||
curl -X PATCH \
|
http://localhost:5000/api/admin/deletion-log
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
|
||||||
-b cookies.txt \
|
|
||||||
-d '{"approved":true}' \
|
|
||||||
http://localhost:5000/api/admin/groups/abc123/approve
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Geschützte Endpoints (Auszug)
|
**Postman/Insomnia**:
|
||||||
|
- Type: `Bearer Token`
|
||||||
|
- Token: `dein-generierter-key-hier`
|
||||||
|
|
||||||
|
### Geschützte Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Beschreibung |
|
| Endpoint | Method | Beschreibung |
|
||||||
|----------|--------|--------------|
|
|----------|--------|--------------|
|
||||||
|
|
@ -77,18 +58,17 @@ curl -X PATCH \
|
||||||
| `/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` | PATCH | Gruppe freigeben |
|
| `/api/admin/groups/:id/approve` | PUT | 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 |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| `401` | Session fehlt oder ist abgelaufen |
|
| `403` | Authorization header fehlt oder ungültig |
|
||||||
| `403` | CSRF ungültig oder Benutzer hat keine Admin-Rolle |
|
| `500` | ADMIN_API_KEY nicht konfiguriert |
|
||||||
| `419` | (optional) Session wurde invalidiert |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -167,43 +147,42 @@ npm test
|
||||||
|
|
||||||
### Manuelles Testen
|
### Manuelles Testen
|
||||||
|
|
||||||
1. **Login**:
|
**Admin-Route ohne Auth**:
|
||||||
```bash
|
```bash
|
||||||
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
|
curl http://localhost:5000/api/admin/deletion-log
|
||||||
-d '{"username":"admin","password":"Secret123"}' \
|
# → 403 Forbidden
|
||||||
http://localhost:5000/auth/login
|
```
|
||||||
```
|
|
||||||
2. **CSRF holen**:
|
**Admin-Route mit Auth**:
|
||||||
```bash
|
```bash
|
||||||
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
|
curl -H "Authorization: Bearer your-key" \
|
||||||
```
|
http://localhost:5000/api/admin/deletion-log
|
||||||
3. **Admin-Route aufrufen**:
|
# → 200 OK
|
||||||
```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_SESSION_SECRET` sicher generieren (>= 32 Bytes random)
|
- [ ] `ADMIN_API_KEY` mit sicherem 64-Zeichen Key setzen
|
||||||
- [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
|
- [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
|
||||||
- [ ] HTTPS verwenden (TLS/SSL) damit Cookies `Secure` gesetzt werden können (falls nicht möglich: `ADMIN_SESSION_COOKIE_SECURE=false` setzen – nur in vertrauenswürdigen Netzen)
|
- [ ] HTTPS verwenden (TLS/SSL)
|
||||||
- [ ] Session-Store auf persistentem Volume ablegen
|
- [ ] Rate Limiting aktiviert prüfen
|
||||||
- [ ] Rate Limiting & Audit Logs überwachen
|
- [ ] Audit Logs regelmäßig überprüfen
|
||||||
- [ ] Admin-Benutzerverwaltung (On-/Offboarding) dokumentieren
|
- [ ] Token-Rotation Policy für Admin-Key implementieren
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sicherheits-Hinweise
|
## Sicherheits-Hinweise
|
||||||
|
|
||||||
### Session-Secret Rotation
|
### Admin-Key Rotation
|
||||||
|
|
||||||
1. Wartungsfenster planen (alle Sessions werden invalidiert)
|
Admin-Key regelmäßig erneuern (z.B. alle 90 Tage):
|
||||||
2. Neuen `ADMIN_SESSION_SECRET` generieren
|
|
||||||
3. `.env` aktualisieren und Backend neu starten
|
1. Neuen Key generieren
|
||||||
|
2. `.env` aktualisieren
|
||||||
|
3. Server neu starten
|
||||||
|
4. Alte Clients auf neuen Key umstellen
|
||||||
|
|
||||||
### Management-Token
|
### Management-Token
|
||||||
|
|
||||||
|
|
@ -213,8 +192,8 @@ npm test
|
||||||
|
|
||||||
### Best Practices
|
### Best Practices
|
||||||
|
|
||||||
- Keine Admin-Secrets im Frontend oder in Repos committen
|
- Admin-Key **nie** im Code hart-kodieren
|
||||||
- Admin-Session-Cookies nur über HTTPS ausliefern
|
- Admin-Key **nie** in Logs/Errors ausgeben
|
||||||
- Rate-Limiting für beide Auth-Typen aktiv halten
|
- Requests über HTTPS (kein Plain-HTTP in Production)
|
||||||
|
- 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)
|
|
||||||
|
|
|
||||||
180
CHANGELOG.md
180
CHANGELOG.md
|
|
@ -1,174 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2.0.1] - 2025-12-01
|
## [Unreleased] - Branch: feature/SocialMedia
|
||||||
|
|
||||||
|
|
||||||
## [2.0.0] - 2025-11-30
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- ENV-Struktur massiv vereinfacht (Phase 6)
|
|
||||||
- Add consent change and deletion notifications (Phase 4)
|
|
||||||
- Add upload notifications to Telegram Bot (Phase 3)
|
|
||||||
- Add TelegramNotificationService (Phase 2)
|
|
||||||
- Add Telegram Bot standalone test (Phase 1)
|
|
||||||
- Add Telegram notification feature request and improve prod.sh Docker registry push
|
|
||||||
|
|
||||||
### 🔧 Chores
|
|
||||||
- Add package.json for Telegram test scripts
|
|
||||||
|
|
||||||
|
|
||||||
## [1.10.2] - 2025-11-29
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- Auto-push releases with --follow-tags
|
|
||||||
|
|
||||||
|
|
||||||
## [1.10.1] - 2025-11-29
|
|
||||||
|
|
||||||
### 🐛 Fixes
|
|
||||||
- Update Footer.js version to 1.10.0 and fix sync-version.sh regex
|
|
||||||
|
|
||||||
### ♻️ Refactoring
|
|
||||||
- Use package.json version directly in Footer instead of env variables
|
|
||||||
|
|
||||||
|
|
||||||
## [1.10.0] - 2025-11-29
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- Enable drag-and-drop reordering in ModerationGroupImagesPage
|
|
||||||
- Error handling system and animated error pages
|
|
||||||
|
|
||||||
### ♻️ Refactoring
|
|
||||||
- Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
|
|
||||||
- Consolidate error pages into single ErrorPage component
|
|
||||||
- Centralized styling with CSS and global MUI overrides
|
|
||||||
|
|
||||||
### 🔧 Chores
|
|
||||||
- Improve release script with tag-based commit detection
|
|
||||||
|
|
||||||
|
|
||||||
## Public/Internal Host Separation (November 25, 2025)
|
|
||||||
|
|
||||||
### 🌐 Public/Internal Host Separation (November 25, 2025)
|
|
||||||
|
|
||||||
#### Backend
|
|
||||||
- ✅ **Host-Based Access Control**: Implemented `hostGate` middleware for subdomain-based feature separation
|
|
||||||
- Public host blocks internal routes: `/api/admin/*`, `/api/groups`, `/api/slideshow`, `/api/social-media/*`, `/api/auth/*`
|
|
||||||
- Public host allows: `/api/upload`, `/api/manage/:token`, `/api/previews`, `/api/consent`, `/api/social-media/platforms`
|
|
||||||
- Host detection via `X-Forwarded-Host` (nginx-proxy-manager) or `Host` header
|
|
||||||
- Environment variables: `PUBLIC_HOST`, `INTERNAL_HOST`, `ENABLE_HOST_RESTRICTION`, `TRUST_PROXY_HOPS`
|
|
||||||
|
|
||||||
- ✅ **Rate Limiting for Public Host**: IP-based upload rate limiting
|
|
||||||
- `publicUploadLimiter`: 20 uploads per hour for public host
|
|
||||||
- Internal host: No rate limits
|
|
||||||
- In-memory tracking with automatic cleanup
|
|
||||||
|
|
||||||
- ✅ **Audit Log Enhancement**: Extended audit logging with source tracking
|
|
||||||
- New columns: `source_host`, `source_type` in `management_audit_log`
|
|
||||||
- Tracks: `req.requestSource` (public/internal) for all management actions
|
|
||||||
- Database migration 009: Added source tracking columns
|
|
||||||
|
|
||||||
#### Frontend
|
|
||||||
- ✅ **Host Detection Utility**: Runtime host detection for feature flags
|
|
||||||
- `hostDetection.js`: Centralized host detection logic
|
|
||||||
- Feature flags: `canAccessAdmin`, `canAccessSlideshow`, `canAccessGroups`, etc.
|
|
||||||
- Runtime config from `window._env_.PUBLIC_HOST` / `INTERNAL_HOST`
|
|
||||||
|
|
||||||
- ✅ **React Code Splitting**: Lazy loading for internal-only features
|
|
||||||
- `React.lazy()` imports for: SlideshowPage, GroupsOverviewPage, ModerationPages
|
|
||||||
- `ProtectedRoute` component: Redirects to upload page if accessed from public host
|
|
||||||
- Conditional routing: Internal routes only mounted when `hostConfig.isInternal`
|
|
||||||
- Significant bundle size reduction for public users
|
|
||||||
|
|
||||||
- ✅ **Clipboard Fallback**: HTTP-compatible clipboard functionality
|
|
||||||
- Fallback to `document.execCommand('copy')` when `navigator.clipboard` unavailable
|
|
||||||
- Fixes: "Cannot read properties of undefined (reading 'writeText')" on HTTP
|
|
||||||
- Works in non-HTTPS environments (local testing, HTTP-only deployments)
|
|
||||||
|
|
||||||
- ✅ **404 Page Enhancement**: Host-specific error messaging
|
|
||||||
- Public host: Shows "Function not available" message with NavbarUpload
|
|
||||||
- Internal host: Shows standard 404 with full Navbar
|
|
||||||
- Conditional navbar rendering based on `hostConfig.isPublic`
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
- ✅ **Environment Setup**: Complete configuration for dev/prod environments
|
|
||||||
- `docker/dev/docker-compose.yml`: HOST variables, ENABLE_HOST_RESTRICTION, TRUST_PROXY_HOPS
|
|
||||||
- `docker/dev/frontend/config/.env`: PUBLIC_HOST, INTERNAL_HOST added
|
|
||||||
- Frontend `.env.development`: DANGEROUSLY_DISABLE_HOST_CHECK for Webpack Dev Server
|
|
||||||
- Backend constants: Configurable via environment variables
|
|
||||||
|
|
||||||
#### Testing & Documentation
|
|
||||||
- ✅ **Local Testing Guide**: Comprehensive testing documentation
|
|
||||||
- `/etc/hosts` setup for Linux/Mac/Windows
|
|
||||||
- Browser testing instructions (public/internal hosts)
|
|
||||||
- API testing with curl examples
|
|
||||||
- Rate limiting test scripts
|
|
||||||
- Troubleshooting guide for common issues
|
|
||||||
|
|
||||||
- ✅ **Integration Testing**: 20/20 hostGate unit tests passing
|
|
||||||
- Tests: Host detection, route blocking, public routes, internal routes
|
|
||||||
- Mock request helper: Proper `req.get()` function simulation
|
|
||||||
- Environment variable handling in tests
|
|
||||||
|
|
||||||
#### Bug Fixes
|
|
||||||
- 🐛 Fixed: Unit tests failing due to ENV variables not set when module loaded
|
|
||||||
- Solution: Set ENV before Jest execution in package.json test script
|
|
||||||
- 🐛 Fixed: `req.get()` mock not returning header values in tests
|
|
||||||
- Solution: Created `createMockRequest()` helper with proper function implementation
|
|
||||||
- 🐛 Fixed: Webpack "Invalid Host header" error with custom hostnames
|
|
||||||
- Solution: Added `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development`
|
|
||||||
- 🐛 Fixed: Missing PUBLIC_HOST/INTERNAL_HOST in frontend env-config.js
|
|
||||||
- Solution: Added variables to `docker/dev/frontend/config/.env`
|
|
||||||
- 🐛 Fixed: Wrong navbar (Navbar instead of NavbarUpload) on 404 page for public host
|
|
||||||
- Solution: Conditional rendering `{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}`
|
|
||||||
- 🐛 Fixed: "Plattformen konnten nicht geladen werden" in UUID Management mode
|
|
||||||
- Solution: Added `/api/social-media/platforms` to PUBLIC_ALLOWED_ROUTES
|
|
||||||
|
|
||||||
#### Technical Details
|
|
||||||
- **Backend Changes**:
|
|
||||||
- New files: `middlewares/hostGate.js`, `middlewares/rateLimiter.js` (publicUploadLimiter)
|
|
||||||
- Modified files: `server.js` (hostGate registration), `auditLog.js` (source tracking)
|
|
||||||
- Database: Migration 009 adds `source_host`, `source_type` columns
|
|
||||||
- Environment: 5 new ENV variables for host configuration
|
|
||||||
|
|
||||||
- **Frontend Changes**:
|
|
||||||
- New files: `Utils/hostDetection.js` (214 lines)
|
|
||||||
- Modified files: `App.js` (lazy loading + ProtectedRoute), `404Page.js` (conditional navbar)
|
|
||||||
- Modified files: `MultiUploadPage.js`, `UploadSuccessDialog.js` (clipboard fallback)
|
|
||||||
- Modified files: `env-config.js`, `public/env-config.js` (HOST variables)
|
|
||||||
- New file: `.env.development` (Webpack host check bypass)
|
|
||||||
|
|
||||||
- **Production Impact**:
|
|
||||||
- nginx-proxy-manager setup required for subdomain routing
|
|
||||||
- Must forward `X-Forwarded-Host` header to backend
|
|
||||||
- Set `TRUST_PROXY_HOPS=1` when behind nginx-proxy-manager
|
|
||||||
- Public host users get 96% smaller JavaScript bundle (code splitting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## feature/SocialMedia
|
|
||||||
|
|
||||||
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
|
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
|
||||||
|
|
||||||
|
|
@ -224,7 +56,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
|
||||||
|
|
@ -417,7 +249,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Preload Image
|
## [Unreleased] - Branch: feature/PreloadImage
|
||||||
|
|
||||||
### 🚀 Slideshow Optimization (November 2025)
|
### 🚀 Slideshow Optimization (November 2025)
|
||||||
|
|
||||||
|
|
@ -454,7 +286,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Delete Unproved Groups
|
## [Unreleased] - Branch: feature/DeleteUnprovedGroups
|
||||||
|
|
||||||
### ✨ Automatic Cleanup Feature (November 2025)
|
### ✨ Automatic Cleanup Feature (November 2025)
|
||||||
|
|
||||||
|
|
@ -521,7 +353,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Image Description
|
## [Unreleased] - Branch: feature/ImageDescription
|
||||||
|
|
||||||
### ✨ Image Descriptions Feature (November 2025)
|
### ✨ Image Descriptions Feature (November 2025)
|
||||||
|
|
||||||
|
|
@ -595,7 +427,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Upgrade Deps: React & Node (October 2025)
|
## [Unreleased] - Branch: upgrade/deps-react-node-20251028
|
||||||
|
|
||||||
### 🎯 Major Framework Upgrades (October 2025)
|
### 🎯 Major Framework Upgrades (October 2025)
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,109 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
# Feature Plan: Telegram Bot Integration
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Implementierung eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
|
|
||||||
|
|
||||||
**Basis:** [FEATURE_REQUEST-telegram.md](./FEATURE_REQUEST-telegram.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phasen-Aufteilung
|
|
||||||
|
|
||||||
### Phase 1: Bot Setup & Standalone-Test
|
|
||||||
**Ziel:** Telegram Bot erstellen und isoliert testen (ohne App-Integration)
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] Telegram Bot via BotFather erstellt
|
|
||||||
- [x] Bot zu Test-Telegram-Gruppe hinzugefügt
|
|
||||||
- [x] Chat-ID ermittelt
|
|
||||||
- [x] `scripts/telegram-test.js` - Standalone Test-Script
|
|
||||||
- [x] `scripts/README.telegram.md` - Setup-Anleitung
|
|
||||||
- [x] `.env.telegram` - Template für Bot-Credentials
|
|
||||||
- [x] Erfolgreiche Test-Nachricht versendet
|
|
||||||
|
|
||||||
**Akzeptanzkriterium:**
|
|
||||||
✅ Bot sendet erfolgreich Nachricht an Testgruppe
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: Backend-Service Integration
|
|
||||||
**Ziel:** TelegramNotificationService in Backend integrieren
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Dependencies:** Phase 1 abgeschlossen
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] `backend/src/services/TelegramNotificationService.js`
|
|
||||||
- [x] ENV-Variablen in `docker/dev/backend/config/.env`
|
|
||||||
- [x] Unit-Tests für Service
|
|
||||||
- [x] Docker Dev Environment funktioniert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: Upload-Benachrichtigungen
|
|
||||||
**Ziel:** Automatische Benachrichtigungen bei neuem Upload
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Dependencies:** Phase 2 abgeschlossen
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] Integration in `routes/batchUpload.js`
|
|
||||||
- [x] `sendUploadNotification()` Methode
|
|
||||||
- [x] Formatierung mit Icons/Emojis
|
|
||||||
- [x] Integration-Tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: User-Änderungs-Benachrichtigungen
|
|
||||||
**Ziel:** Benachrichtigungen bei Consent-Änderungen & Löschungen
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Dependencies:** Phase 3 abgeschlossen
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] Integration in `routes/management.js` (PUT/DELETE)
|
|
||||||
- [x] `sendConsentChangeNotification()` Methode
|
|
||||||
- [x] `sendGroupDeletedNotification()` Methode
|
|
||||||
- [x] Integration-Tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 5: Tägliche Lösch-Warnungen
|
|
||||||
**Ziel:** Cron-Job für bevorstehende Löschungen
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Dependencies:** Phase 4 abgeschlossen
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] Cron-Job Setup (node-cron)
|
|
||||||
- [x] `sendDeletionWarning()` Methode
|
|
||||||
- [x] Admin-Route für manuellen Trigger (`POST /api/admin/telegram/warning`)
|
|
||||||
- [x] SchedulerService Integration (09:00 daily)
|
|
||||||
- [x] Docker ENV-Variablen konfiguriert
|
|
||||||
- [x] README.md Update
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 6: Production Deployment
|
|
||||||
**Ziel:** Rollout in Production-Umgebung + ENV-Vereinfachung
|
|
||||||
|
|
||||||
**Status:** 🟢 Abgeschlossen
|
|
||||||
|
|
||||||
**Dependencies:** Phase 1-5 abgeschlossen + getestet
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] ENV-Struktur vereinfachen (zu viele .env-Dateien!)
|
|
||||||
- [x] Production ENV-Variablen in docker/prod/.env konfigurieren
|
|
||||||
- [x] docker/prod/docker-compose.yml mit Telegram-ENV erweitern
|
|
||||||
- [x] Consent-Änderung Bug Fix (platform_name statt name)
|
|
||||||
- [x] README.md Update mit ENV-Struktur Dokumentation
|
|
||||||
- ⏭️ Bot in echte Werkstatt-Gruppe einfügen (optional, bei Bedarf)
|
|
||||||
- ⏭️ Production Testing (optional, bei Bedarf)
|
|
||||||
|
|
||||||
**ENV-Vereinfachung (Abgeschlossen):**
|
|
||||||
```
|
|
||||||
Vorher: 16 .env-Dateien mit redundanter Konfiguration
|
|
||||||
Nachher: 2 zentrale .env-Dateien
|
|
||||||
✅ docker/dev/.env (alle dev secrets)
|
|
||||||
✅ docker/prod/.env (alle prod secrets)
|
|
||||||
✅ docker-compose.yml nutzt ${VAR} Platzhalter
|
|
||||||
✅ Gemountete .env-Dateien entfernt (wurden überschrieben)
|
|
||||||
✅ Alle ENV-Variablen in docker-compose environment
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 - Detaillierter Plan
|
|
||||||
|
|
||||||
### 1. Vorbereitung (5 min)
|
|
||||||
|
|
||||||
**Auf Windows 11 Host-System:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Node.js Version prüfen
|
|
||||||
node --version # Sollte >= 18.x sein
|
|
||||||
|
|
||||||
# Projektverzeichnis öffnen
|
|
||||||
cd /home/lotzm/gitea.hobbyhimmel/Project-Image-Uploader/scripts
|
|
||||||
|
|
||||||
# Dependencies installieren (lokal)
|
|
||||||
npm init -y # Falls noch keine package.json
|
|
||||||
npm install node-telegram-bot-api dotenv
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Telegram Bot erstellen (10 min)
|
|
||||||
|
|
||||||
**Anleitung:** Siehe `scripts/README.telegram.md`
|
|
||||||
|
|
||||||
**Schritte:**
|
|
||||||
1. Telegram öffnen (Windows 11 App)
|
|
||||||
2. [@BotFather](https://t.me/botfather) suchen
|
|
||||||
3. `/newbot` Command
|
|
||||||
4. Bot-Name: "Werkstatt Image Uploader Bot"
|
|
||||||
5. Username: `werkstatt_uploader_bot` (oder verfügbar)
|
|
||||||
6. **Token kopieren** → `.env.telegram`
|
|
||||||
|
|
||||||
### 3. Test-Gruppe erstellen & Bot hinzufügen (5 min)
|
|
||||||
|
|
||||||
**Schritte:**
|
|
||||||
1. Neue Telegram-Gruppe erstellen: "Werkstatt Upload Bot Test"
|
|
||||||
2. Bot zur Gruppe hinzufügen: @werkstatt_uploader_bot
|
|
||||||
3. **Chat-ID ermitteln** (siehe README.telegram.md)
|
|
||||||
4. Chat-ID speichern → `.env.telegram`
|
|
||||||
|
|
||||||
### 4. Test-Script erstellen (10 min)
|
|
||||||
|
|
||||||
**Datei:** `scripts/telegram-test.js`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Lädt `.env.telegram`
|
|
||||||
- Validiert Bot-Token
|
|
||||||
- Sendet Test-Nachricht
|
|
||||||
- Error-Handling
|
|
||||||
|
|
||||||
### 5. Erste Nachricht senden (2 min)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd scripts
|
|
||||||
node telegram-test.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Erwartete Ausgabe:**
|
|
||||||
```
|
|
||||||
✅ Telegram Bot erfolgreich verbunden!
|
|
||||||
Bot-Name: Werkstatt Image Uploader Bot
|
|
||||||
Bot-Username: @werkstatt_uploader_bot
|
|
||||||
|
|
||||||
📤 Sende Test-Nachricht an Chat -1001234567890...
|
|
||||||
✅ Nachricht erfolgreich gesendet!
|
|
||||||
```
|
|
||||||
|
|
||||||
**In Telegram-Gruppe:**
|
|
||||||
```
|
|
||||||
🤖 Telegram Bot Test
|
|
||||||
|
|
||||||
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
|
|
||||||
|
|
||||||
Status: ✅ Erfolgreich verbunden!
|
|
||||||
Zeitstempel: 2025-11-29 14:23:45
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dateistruktur (Phase 1)
|
|
||||||
|
|
||||||
```
|
|
||||||
scripts/
|
|
||||||
├── README.telegram.md # Setup-Anleitung (NEU)
|
|
||||||
├── telegram-test.js # Test-Script (NEU)
|
|
||||||
├── .env.telegram.example # ENV-Template (NEU)
|
|
||||||
├── .env.telegram # Echte Credentials (gitignored, NEU)
|
|
||||||
├── package.json # Lokale Dependencies (NEU)
|
|
||||||
└── node_modules/ # npm packages (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables (Phase 1)
|
|
||||||
|
|
||||||
**Datei:** `scripts/.env.telegram`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Telegram Bot Configuration
|
|
||||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
||||||
TELEGRAM_CHAT_ID=-1001234567890
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies (Phase 1)
|
|
||||||
|
|
||||||
**Package:** `scripts/package.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "telegram-test-scripts",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Standalone Telegram Bot Testing",
|
|
||||||
"main": "telegram-test.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "node telegram-test.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
|
||||||
"dotenv": "^16.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sicherheit (Phase 1)
|
|
||||||
|
|
||||||
**`.gitignore` ergänzen:**
|
|
||||||
|
|
||||||
```
|
|
||||||
# Telegram Credentials
|
|
||||||
scripts/.env.telegram
|
|
||||||
scripts/node_modules/
|
|
||||||
scripts/package-lock.json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wichtig:**
|
|
||||||
- ❌ Niemals `.env.telegram` committen!
|
|
||||||
- ✅ Nur `.env.telegram.example` (ohne echte Tokens) committen
|
|
||||||
- ✅ Bot-Token regenerieren, falls versehentlich exposed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist (Phase 1)
|
|
||||||
|
|
||||||
- [x] Node.js Version >= 18.x
|
|
||||||
- [x] Telegram App installiert (Windows 11)
|
|
||||||
- [x] Bot via BotFather erstellt
|
|
||||||
- [x] Bot-Token gespeichert in `.env.telegram`
|
|
||||||
- [x] Test-Gruppe erstellt
|
|
||||||
- [x] Bot zur Gruppe hinzugefügt
|
|
||||||
- [x] Chat-ID ermittelt
|
|
||||||
- [x] Chat-ID gespeichert in `.env.telegram`
|
|
||||||
- [x] Privacy Mode deaktiviert
|
|
||||||
- [x] Test-Nachricht erfolgreich gesendet
|
|
||||||
- [ ] `npm install` erfolgreich
|
|
||||||
- [ ] `node telegram-test.js` läuft ohne Fehler
|
|
||||||
- [ ] Test-Nachricht in Telegram-Gruppe empfangen
|
|
||||||
- [ ] Formatierung (Emojis, Zeilenumbrüche) korrekt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting (Phase 1)
|
|
||||||
|
|
||||||
### Problem: "Unauthorized (401)"
|
|
||||||
**Lösung:** Bot-Token falsch → BotFather prüfen, `.env.telegram` korrigieren
|
|
||||||
|
|
||||||
### Problem: "Bad Request: chat not found"
|
|
||||||
**Lösung:** Chat-ID falsch → Neue Nachricht in Gruppe senden, Chat-ID neu ermitteln
|
|
||||||
|
|
||||||
### Problem: "ETELEGRAM: 403 Forbidden"
|
|
||||||
**Lösung:** Bot wurde aus Gruppe entfernt → Bot erneut zur Gruppe hinzufügen
|
|
||||||
|
|
||||||
### Problem: "Module not found: node-telegram-bot-api"
|
|
||||||
**Lösung:**
|
|
||||||
```bash
|
|
||||||
cd scripts
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nächste Schritte (nach Phase 1)
|
|
||||||
|
|
||||||
1. **Code-Review:** `scripts/telegram-test.js`
|
|
||||||
2. **Dokumentation Review:** `scripts/README.telegram.md`
|
|
||||||
3. **Commit:**
|
|
||||||
```bash
|
|
||||||
git add scripts/
|
|
||||||
git commit -m "feat: Add Telegram Bot standalone test (Phase 1)"
|
|
||||||
```
|
|
||||||
4. **Phase 2 starten:** Backend-Integration planen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zeitschätzung
|
|
||||||
|
|
||||||
| Phase | Aufwand | Beschreibung |
|
|
||||||
|-------|---------|--------------|
|
|
||||||
| **Phase 1** | **~45 min** | Bot Setup + Standalone-Test |
|
|
||||||
| Phase 2 | ~2h | Backend-Service |
|
|
||||||
| Phase 3 | ~2h | Upload-Benachrichtigungen |
|
|
||||||
| Phase 4 | ~2h | Änderungs-Benachrichtigungen |
|
|
||||||
| Phase 5 | ~2h | Cron-Job |
|
|
||||||
| Phase 6 | ~1h | Production Deployment |
|
|
||||||
| **Gesamt** | **~9-10h** | Vollständige Integration |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conventional Commits (ab Phase 1)
|
|
||||||
|
|
||||||
**Phase 1:**
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: Add Telegram Bot test script"
|
|
||||||
git commit -m "docs: Add Telegram Bot setup guide"
|
|
||||||
git commit -m "chore: Add node-telegram-bot-api dependency to scripts"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Phase 2:**
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: Add TelegramNotificationService"
|
|
||||||
git commit -m "test: Add TelegramNotificationService unit tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Phase 3-6:**
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: Add upload notification to Telegram"
|
|
||||||
git commit -m "feat: Add consent change notifications"
|
|
||||||
git commit -m "feat: Add daily deletion warnings cron job"
|
|
||||||
git commit -m "docs: Update README with Telegram features"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release-Planung
|
|
||||||
|
|
||||||
**Phase 1:** Kein Release (interne Tests)
|
|
||||||
|
|
||||||
**Phase 6 (Final):**
|
|
||||||
- **Version:** `2.0.0` (Major Release)
|
|
||||||
- **Branch:** `feature/telegram-notifications`
|
|
||||||
- **Release-Command:** `npm run release:major`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status-Tracking
|
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2025-11-30
|
|
||||||
|
|
||||||
| Phase | Status | Datum |
|
|
||||||
|-------|--------|-------|
|
|
||||||
| Phase 1 | 🟢 Abgeschlossen | 2025-11-29 |
|
|
||||||
| Phase 2 | 🟢 Abgeschlossen | 2025-11-29 |
|
|
||||||
| Phase 3 | 🟢 Abgeschlossen | 2025-11-29 |
|
|
||||||
| Phase 4 | 🟢 Abgeschlossen | 2025-11-30 |
|
|
||||||
| Phase 5 | 🟢 Abgeschlossen | 2025-11-30 |
|
|
||||||
| Phase 6 | 🟡 ENV vereinfacht | 2025-11-30 |
|
|
||||||
|
|
||||||
**Legende:**
|
|
||||||
- 🟢 Abgeschlossen
|
|
||||||
- 🟡 In Arbeit
|
|
||||||
- 🔴 Blockiert
|
|
||||||
- ⚪ Ausstehend
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
<!--
|
|
||||||
Feature Request: Public vs. Intranet UI/API by Subdomain
|
|
||||||
Datei erstellt: 22.11.2025
|
|
||||||
-->
|
|
||||||
# Feature Request: Frontend/Public API per Subdomain
|
|
||||||
|
|
||||||
## Kurzbeschreibung
|
|
||||||
|
|
||||||
Es soll unterschieden werden, welche Funktionen der App abhängig von der aufgerufenen Subdomain verfügbar sind:
|
|
||||||
|
|
||||||
- `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar.
|
|
||||||
- `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend.
|
|
||||||
|
|
||||||
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet (dns challenge letsencrypt).
|
|
||||||
|
|
||||||
|
|
||||||
## Ziele
|
|
||||||
|
|
||||||
- Sicherheit: Slideshow, Groupview und Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen.
|
|
||||||
- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar. (die Upload Seite ist bereits so gestalltet, dass keine Menüpunkte sichtbar sind)
|
|
||||||
|
|
||||||
|
|
||||||
## Vorschlag — Technische Umsetzung (hoher Level)
|
|
||||||
|
|
||||||
1) Host-Erkennung
|
|
||||||
- Backend und Frontend erkennen die Subdomain via `Host` bzw. `X-Forwarded-Host` Header. Alternativ über eine runtime `env-config.js` (`/public/env-config.js`) die beim Request vom Backend dynamisch befüllt wird.
|
|
||||||
|
|
||||||
2) Backend: Gatekeeping-Middleware
|
|
||||||
- Neue Middleware (z.B. `middlewares/hostGate.js`) prüft `req.hostname` / `x-forwarded-host`.
|
|
||||||
- Wenn Request von öffentlicher Subdomain: schränke verfügbare API-Routen ein — nur `/api/upload` und `/api/manage/:token` (oder die minimalen Endpoints) werden zugelassen.
|
|
||||||
- Wenn Request von interner Subdomain: volle Route-Registrierung (Admin, System, Migration usw.).
|
|
||||||
- Schleifen-/Edge-Cases: Allowlist für einzelne externe Hosts (z. B. externe public-frontend-Host), sodass ein extern gehostetes UI die public-API nutzen darf.
|
|
||||||
|
|
||||||
3) Frontend: Menü- und Feature-Visibility
|
|
||||||
- Beim Laden prüft das Frontend `window.location.host` (oder die runtime `env-config.js`).
|
|
||||||
- Wenn public-host: Navigation reduziert — nur Upload, ggf. Hilfe/Impressum. Alle Buttons/Links zu Moderation/Slideshow/Gruppen ausgeblendet/gesperrt.
|
|
||||||
- Wenn internal-host: komplette Navigation und Admin-Funktionen sichtbar.
|
|
||||||
|
|
||||||
4) Reverse Proxy / nginx
|
|
||||||
- `nginx-proxy-manager` muss Host-Header weiterreichen (standard). Wichtig: `proxy_set_header Host $host;` so dass Backend den Host erkennt.
|
|
||||||
- SSL: bereits vorhanden für beide Host-Namespaces (extern + lan).
|
|
||||||
- Alternative: Public-Frontend extern hosten -> Proxy/Firewall so konfigurieren, dass nur die erlaubten API-Routen erreichbar sind (oder API-Server hinter VPN nur für `*.lan.` erreichbar).
|
|
||||||
|
|
||||||
5) CORS & Security
|
|
||||||
- Public-API: enge CORS-Regel (nur erlaubte public-frontend-origin, falls extern gehostet).
|
|
||||||
- Rate-Limiting für public Uploads stärker setzen.
|
|
||||||
- Upload-Validierung (Dateityp, Größe), Scanner/Virus-Check bedenken.
|
|
||||||
|
|
||||||
## Akzeptanzkriterien (Metrisch / Testbar)
|
|
||||||
|
|
||||||
- Auf `deinprojekt.meindomain.de` sind nur Upload und Management-by-UUID erreichbar — Aufrufe von `/api/admin/*` erhalten 403/404.
|
|
||||||
- Auf `deinprojekt.lan.meindomain.de` sind Admin- und Moderation-Endpunkte erreichbar und die Navigation zeigt alle Menüpunkte.
|
|
||||||
- Unit-/Integrationstest: Backend-Middleware hat Tests für Host-Varianten (public/internal/external-frontend)
|
|
||||||
- End-to-End: Test-Upload über public-host funktioniert, Moderation-API von dort nicht.
|
|
||||||
|
|
||||||
## Änderungsumfang (Konkrete Dateien/Orte)
|
|
||||||
|
|
||||||
- Backend
|
|
||||||
- `src/middlewares/hostGate.js` (neu) — enthält Host-Prüfung und Policy
|
|
||||||
- `src/server.js` / `src/index.js` — Routen nur registrieren oder mounten, falls Host-Policy es erlaubt; oder Middleware pro Route
|
|
||||||
- `src/middlewares/auth.js` — ggf. anpassen, um Host-Checks in Kombination mit Auth zu berücksichtigen
|
|
||||||
|
|
||||||
- Frontend
|
|
||||||
- `public/env-config.js` (runtime) oder `env-config.js` (build-time) — Flag `PUBLIC_MODE=true/false` bzw. `APP_ALLOWED_FEATURES`
|
|
||||||
- Menü-Komponenten (z. B. `Components/Pages/*`) — Feature-Visibility anhand `window.location.host` oder runtime-config
|
|
||||||
|
|
||||||
- Infrastruktur
|
|
||||||
- `docker/dev/*` nginx-proxy-manager Konfiguration prüfen: Host-Header, Zertifikate
|
|
||||||
|
|
||||||
## Sicherheits-Überlegungen
|
|
||||||
|
|
||||||
- Admin-Endpoints müssen serverseitig geblockt sein — niemals nur per Frontend-UI verstecken.
|
|
||||||
- Public Uploads: individuelle Rate-Limits, Captcha-Optionen, Virus/Malware-Scanning.
|
|
||||||
- Logging & Audit: Uploads von extern sollten besondere Logging-Flags bekommen (IP, Host, Herkunfts-Header).
|
|
||||||
|
|
||||||
## Fragen / Punkte zur Konkretisierung — und Antworten aus der Projektdokumentation
|
|
||||||
|
|
||||||
Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICATION.md` habe ich viele offene Punkte direkt beantwortet und die verbleibenden Entscheidungen auf das Nötigste reduziert. Unten: jeweils die Frage, was die Doku bereits festlegt, und die noch offenen Bestätigungen, die Du kurz geben solltest.
|
|
||||||
|
|
||||||
1. Domains — exakte Hosts
|
|
||||||
- Dokumentation: Platzhalter-Hosts wurden als Beispiele verwendet (z. B. `deinprojekt.meindomain.de` und `deinprojekt.lan.meindomain.de`).
|
|
||||||
- Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. Beispiel‑Antwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`.
|
|
||||||
|
|
||||||
2. Host-Check vs. zusätzliche Checks
|
|
||||||
- Doku: Admin‑API ist bereits serverseitig per Admin Login geschützt. Management‑API nutzt UUID‑Token mit Rate‑Limits (10 req/h) und Brute‑Force‑Schutz.
|
|
||||||
- Empfehlung: Primär Host‑Header (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für Admin‑APIs die Kombination aus Bearer‑Token + Host‑Check (defense in depth). Bitte bestätigen, ob IP‑Whitelist gewünscht ist.
|
|
||||||
|
|
||||||
3. Externes Hosting des public‑Frontends -> nicht mehr nötig
|
|
||||||
|
|
||||||
4. Management‑UUID (Editieren von extern)
|
|
||||||
- Doku: Management‑Tokens sind permanent gültig bis Gruppe gelöscht; Token sind URL‑basiert und Rate‑limited (10 req/h). README zeigt, dass Management‑Portal für Self‑Service gedacht ist und kein zusätzliches network restriction vorgesehen ist.
|
|
||||||
- Schlussfolgerung: Editieren per UUID ist technisch erlaubt und im Projekt vorgesehen. Wenn Du das beibehalten willst, ist keine weitere technische Änderung nötig. Falls Du TTL für Tokens möchtest, bitte angeben.
|
|
||||||
|
|
||||||
5. Admin‑APIs: Host‑only oder zusätzlich Bearer‑Token?
|
|
||||||
- ~~Doku: Admin APIs sind bereits durch Bearer‑Token geschützt (`ADMIN_API_KEY`).~~
|
|
||||||
- ~~Empfehlung: Behalte Bearer‑Token als Hauptschutz und ergänze Host‑Restriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen.~~
|
|
||||||
|
|
||||||
6. Rate‑Limits / Quotas für public Uploads
|
|
||||||
- Doku: Management hat 10 req/h per IP; Upload‑Rate‑Limits für public uploads sind nicht konkret spezifiziert.
|
|
||||||
- Vorschlag: Default `20 uploads / IP / Stunde` für public subdomain + strengere throttling für unauthenticated bursts. Bestätige oder nenne anderes Limit.
|
|
||||||
|
|
||||||
7. Logging / Monitoring
|
|
||||||
- Doku: Es gibt umfassende Audit-Logs (`management_audit_log`, `deletion_log`).
|
|
||||||
- Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen? Passt!
|
|
||||||
|
|
||||||
8. Assets / CDN
|
|
||||||
- Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUID‑Links zugänglich.
|
|
||||||
- Entscheidung: Default bleibt interne Auslieferung. Externe CDN-Auslieferung ist möglich, aber muss aus Privacy/Access‑Control‑Gründen extra implementiert (signed URLs, TTL, ACLs). Keine Aktion nötig, wenn Du interne Auslieferung beibehältst.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Bitte bestätige die wenigen noch offenen Punkte (Hosts, public‑group‑view ja/nein (siehe unten), Management‑UUID extern ja/nein (bestätigt als ja), desired rate‑limit, zusätzliche Admin‑restrictions, logging‑label). Ich habe die Dokumentation soweit wie möglich angepasst (siehe Änderungen weiter unten). Sobald Du diese 3–4 Punkte bestätigst, erstelle ich die konkreten Patches (Middleware, kleine Frontend‑Visibility‑Änderung, Tests, README‑Erweiterung).
|
|
||||||
## Vorschlag: Minimal umsetzbare erste Iteration (MVP)
|
|
||||||
|
|
||||||
1. Implementiere `middlewares/hostGate.js` mit einfacher Host-Allowlist (`PUBLIC_HOSTS`, `INTERNAL_HOSTS` in Env).
|
|
||||||
2. Im Backend: prüfe bei jedem Request, ob Route erlaubt ist — für public-Hosts nur Upload & manage-by-uuid.
|
|
||||||
3. Im Frontend: beim Laden `window.location.host` prüfen und Navigation entsprechend reduzieren.
|
|
||||||
4. Dokumentation: `README.dev.md` (API-Abschnitt) und `frontend/MIGRATION-GUIDE.md` um Hinweise erweitern.
|
|
||||||
5. Tests: Unit-Test für Middleware + Integrationstest (Supertest) der Host-Policies.
|
|
||||||
|
|
||||||
## Nächste Schritte / Implementierungs-Checklist
|
|
||||||
|
|
||||||
- [ ] Domain-/Host-Liste bestätigen (Fragen beantworten)
|
|
||||||
- [ ] Env-Variablen definieren und in `docker/*/config/.env` ergänzen
|
|
||||||
- [ ] Backend: Middleware implementieren + Tests
|
|
||||||
- [ ] Frontend: runtime `env-config.js` + menu-visibility implementieren
|
|
||||||
- [ ] Proxy/Nginx: Konfiguration prüfen & SSL testen
|
|
||||||
- [ ] Review & Security-Check (rate-limits, CORS, logging)
|
|
||||||
- [ ] Deployment-Plan / Rollout-Notes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
Bitte beantwort e die Fragen im Abschnitt "Fragen / Punkte zur Konkretisierung" oben. Sobald ich Deine Antworten habe, kann ich ein konkretes Implementierungs-Patch (Backend-Middleware + Frontend-Visibility) erstellen und Tests hinzufügen.
|
|
||||||
|
|
||||||
## FAQ: Trennung von Frontend und Backend — Frage des Nutzers & Antwort
|
|
||||||
|
|
||||||
**Frage:**
|
|
||||||
Wie kann ich Frontend und Backend räumlich trennen, ohne dass sicherheitsrelevante (Bilder) theoretisch extern erreichbar sind? Im Moment laufen Frontend und Backend in einer Docker Compose. Ist das sinnvoll? Wie trennt man das am Besten?
|
|
||||||
|
|
||||||
**Antwort (zusammengefasst & praxisorientiert):**
|
|
||||||
|
|
||||||
- Development: Für lokale Entwicklung ist eine gemeinsame `docker-compose`-Umgebung (Frontend + Backend) sinnvoll — HMR, einfache Debugging und Tests.
|
|
||||||
- Produktion: Trennung empfohlen. Ziel: Frontend öffentlich (statisch/CDN) betreiben, Backend streng begrenzen und Assets (Originale/Previews) niemals ohne Authorisierung öffentlich machen.
|
|
||||||
|
|
||||||
Architektur-Optionen (Kurz):
|
|
||||||
- Single-Server mit `nginx` Reverse‑Proxy (empfohlen, einfach): `nginx` routet `/` zum statischen Frontend und `/api/*` zum Backend; Backend nicht direkt öffentlich.
|
|
||||||
- Frontend extern (CDN/Netlify) + Backend intern hinter Reverse‑Proxy: Frontend ist skalierbar, Backend nur über Proxy erreichbar; für Bilder: presigned URLs oder Backend‑Proxy verwenden.
|
|
||||||
- Vollständige Trennung (Backend nur im privaten Netz / VPN): Sehr sicher, aber komplexer (VPN/VPC). Admin-/Moderation nur über LAN/VPN erreichbar.
|
|
||||||
|
|
||||||
Wie Bilder sicher halten (Pattern):
|
|
||||||
- Pattern A — Backend‑proxied images: Bilder nur auf Backend speichern; Zugriff nur über Backend‑Endpunkte (prüfen Management‑UUID / Host), keine direkte öffentliche URL.
|
|
||||||
- Pattern B — Private Object Storage + presigned URLs: Nutze privaten S3/Bucket; generiere kurzlebige presigned URLs nach Auth/Zugriffsprüfung; kombiniere mit CDN (Origin Access).
|
|
||||||
- Pattern C — CDN + signed URLs für Previews: Nur Previews via CDN mit signed URLs; Originals bleiben intern oder ebenfalls presigned.
|
|
||||||
|
|
||||||
Konkrete Maßnahmen (umsetzbar sofort):
|
|
||||||
1. Reverse‑Proxy (`nginx`) einführen: zwei vhosts (public / internal). Auf public vhost `/api/admin` und `/groups` blockieren; nur `/api/upload` und `/api/manage/:token` erlauben.
|
|
||||||
2. Docker‑Netzwerke: Backend in `internal_net` ohne veröffentlichte Ports; `reverse-proxy` hat öffentliche Ports und verbindet zu `backend` intern.
|
|
||||||
3. Host‑Gate Middleware (Express): `req.isPublic` setzen via `Host`/`X-Forwarded-Host`, serverseitig Routen (Admin/Groups) für public blocken — defense in depth.
|
|
||||||
4. CORS & Rate‑Limit: CORS auf erlaubte Origins, strengere Rate‑Limits für public Uploads (z. B. 20 Uploads/IP/Stunde) und Captcha prüfen.
|
|
||||||
5. Logging: Audit‑Logs erweitern (z. B. `source_host`) um public vs internal Uploads zu unterscheiden.
|
|
||||||
|
|
||||||
Beispiel nginx‑Snippet (konzeptionell):
|
|
||||||
|
|
||||||
```
|
|
||||||
server {
|
|
||||||
server_name public.example.com;
|
|
||||||
location / { root /usr/share/nginx/html; try_files $uri /index.html; }
|
|
||||||
location ~ ^/api/(upload|manage) { proxy_pass http://backend:5001; proxy_set_header Host $host; }
|
|
||||||
location ~ ^/api/admin { return 403; }
|
|
||||||
location ~ ^/groups { return 403; }
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
server_name internal.lan.example.com;
|
|
||||||
location / { proxy_pass http://frontend:3000; }
|
|
||||||
location /api/ { proxy_pass http://backend:5001; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Docker‑Compose Hinweis (prod): Backend ohne `ports:` veröffentlichen; `reverse-proxy` expose Ports 80/443 und verbindet intern:
|
|
||||||
|
|
||||||
```
|
|
||||||
services:
|
|
||||||
reverse-proxy:
|
|
||||||
ports: ["80:80","443:443"]
|
|
||||||
networks: [public_net, internal_net]
|
|
||||||
backend:
|
|
||||||
networks: [internal_net]
|
|
||||||
# no ports
|
|
||||||
networks:
|
|
||||||
internal_net:
|
|
||||||
internal: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Checklist (schnell umsetzbar)
|
|
||||||
- [ ] `nginx` reverse‑proxy hinzufügen
|
|
||||||
- [ ] Backend‑Ports entfernen (nur interner Zugriff)
|
|
||||||
- [ ] vhost‑Regeln: public vs internal (Admin blockieren auf public)
|
|
||||||
- [ ] `hostGate` middleware implementieren (Express)
|
|
||||||
- [ ] CORS, Rate‑Limit, Captcha konfigurieren
|
|
||||||
- [ ] Audit‑Log `source_host` ergänzen
|
|
||||||
|
|
||||||
Wenn Du möchtest, implementiere ich als nächsten Schritt die `hostGate`‑Middleware, Beispiel‑nginx‑VHosts und die `docker-compose`‑Änderungen als Patch hier im Repository. Sag mir kurz, welche Hostnames (Platzhalter sind OK) und ob Du Frontend lokal im selben Host behalten willst oder extern hosten willst.
|
|
||||||
|
|
||||||
## Technische Details & Voraussetzungen
|
|
||||||
|
|
||||||
Im Folgenden findest Du eine vertiefte, technische Zusammenfassung der Architekturoptionen, Voraussetzungen und Sicherheitsmaßnahmen — als Entscheidungsgrundlage für die Implementierung des Subdomain‑abhängigen Verhaltens.
|
|
||||||
|
|
||||||
1) Ziel und Sicherheitsprinzip
|
|
||||||
- Zweck: Subdomain‑abhängig unterschiedliche UX und API‑Zugänglichkeit (Public: Upload + Manage-UUID; Intranet: Full‑Feature).
|
|
||||||
- Sicherheitsprinzip: Nie ausschließlich auf Frontend‑Steuerung vertrauen — serverseitige Blockierung ist Pflicht.
|
|
||||||
|
|
||||||
2) Infrastruktur‑Varianten
|
|
||||||
- Variante A — Single Host + `nginx` Reverse‑Proxy: Einfach, kontrollierbar, Proxy hostet TLS, routet an Backend; Backend nicht direkt erreichbar.
|
|
||||||
- Variante B — Frontend extern (CDN/Netlify) + Backend intern: Skalierbar; Bilder per presigned URLs oder Backend‑Proxy ausliefern.
|
|
||||||
- Variante C — Backend nur im privaten Netz/VPN: Höchste Sicherheit, mehr Betriebskomplexität.
|
|
||||||
|
|
||||||
3) Host‑Erkennung und Defense in Depth
|
|
||||||
- Proxy muss `Host` bzw. `X-Forwarded-Host` weiterreichen (`proxy_set_header Host $host`).
|
|
||||||
- Implementiere serverseitig eine `hostGate`‑Middleware, die `req.isPublic` bzw. `req.isInternal` setzt und schütze kritische Routen zusätzlich (Admin, Groups Listing, Cleanup).
|
|
||||||
- Kombiniere Proxy‑Regeln + Middleware + Bearer‑Token (für Admin) + Firewall für maximale Sicherheit.
|
|
||||||
|
|
||||||
4) Speicherung und Auslieferung von Bildern
|
|
||||||
- Standard: Bilder lokal in `backend/src/data/images` und `.../previews`.
|
|
||||||
- Pattern A (empfohlen kleinbetrieblich): Backend‑proxied images — keine direkten öffentlichen Pfade; Backend kontrolliert Zugriffe (UUID, Host).
|
|
||||||
- Pattern B (Skalierung): Privater Object‑Store (S3‑compatible) + presigned URLs (TTL kurz) + CDN (Origin Access) für Performance.
|
|
||||||
- Previews können weniger restriktiv gehandhabt werden (kurze TTLs / signed URLs), Originals sollten restriktiver sein.
|
|
||||||
|
|
||||||
5) Management‑UUID (Risiken & Optionen)
|
|
||||||
- Aktuell: UUIDs permanent gültig bis Löschung (convenience). Risiko: Leak bedeutet Zugriff.
|
|
||||||
- Optionen: Beibehalten + Rate‑Limit/Audit (empfohlen), oder TTL/Rotation/Opt‑in Passwortschutz (sicherer, schlechtere UX).
|
|
||||||
|
|
||||||
6) CORS, CSRF, TLS
|
|
||||||
- CORS: Nur erlaubte Origins eintragen (public frontend origin(s) und/oder intranet origin).
|
|
||||||
- CSRF: REST API mit token/UUID im Pfad ist weniger CSRF‑anfällig, trotzdem sicherheitsbewusst durchführen.
|
|
||||||
- TLS/HSTS: Pflicht für öffentliche Hosts.
|
|
||||||
|
|
||||||
7) Rate‑Limiting und Abuse‑Protection
|
|
||||||
- Public Uploads streng limitieren (z. B. 20 uploads/IP/Stunde) + Dateigrößenlimits + MIME/Exif/Type‑Validation.
|
|
||||||
- Optional Captcha für Uploads bei hohem Traffic/Abuse Verdacht.
|
|
||||||
|
|
||||||
8) Logging und Monitoring
|
|
||||||
- Ergänze Audit‑Logs um `source_host`/`source_type` und `request_origin`.
|
|
||||||
- Metriken für rate‑limit hits, 403s, upload errors, health checks; optional Sentry/Prometheus.
|
|
||||||
|
|
||||||
9) Docker/Bereitstellungsempfehlungen
|
|
||||||
- Dev: `docker/dev/docker-compose.yml` mit exposed ports OK.
|
|
||||||
- Prod: Backend keinem Hostport aussetzen (`ports:` entfernen). Reverse‑proxy exponiert 80/443; backend nur im internen Docker‑Netz.
|
|
||||||
- Verwende ein `internal` Docker‑Netzwerk oder separate Netzwerke für Public/Private.
|
|
||||||
|
|
||||||
10) nginx‑proxy‑manager Hinweise
|
|
||||||
- Konfiguriere Proxy‑Hosts für public vs. internal mit passenden Headern (`Host`, `X-Forwarded-*`).
|
|
||||||
- Verwende Proxy‑Regeln, um `/api/admin` & `/groups` auf public Host zu blocken; teste mit `curl`.
|
|
||||||
|
|
||||||
11) Deployment‑Prerequisites (konkret)
|
|
||||||
- DNS für beide Subdomains (public + intranet) vorhanden.
|
|
||||||
- TLS für public (Let's Encrypt) und internes Zertifikat für LAN.
|
|
||||||
- `ADMIN_API_KEY` sicher gesetzt, `PUBLIC_HOSTS` / `INTERNAL_HOSTS` konfiguriert.
|
|
||||||
- Backup‑/Restore‑Policy für DB & images.
|
|
||||||
|
|
||||||
12) Entscheidungsfragen / Tradeoffs
|
|
||||||
- UUID permanent vs TTL: UX vs Security.
|
|
||||||
- Previews via CDN vs Backend‑Proxy: Performance vs Kontrolle.
|
|
||||||
- Frontend lokal hinter nginx vs extern gehostet: Einfachheit vs Skalierbarkeit.
|
|
||||||
|
|
||||||
13) Prüfbare Akzeptanzkriterien (Beispiele)
|
|
||||||
- `curl -I https://public.example.com/api/admin/deletion-log` → 403
|
|
||||||
- Upload via public Host funktioniert (POST to `/api/upload`), Moderation API returns 403.
|
|
||||||
- Backend nicht per `docker ps`/published port extern erreichbar.
|
|
||||||
|
|
||||||
14) Vorschlag: nächste non‑implementierende Schritte
|
|
||||||
- Definiere endgültig: Public/Internal Hostnames; Management‑UUID Policy (TTL ja/nein); Rate‑Limit Wert; CDN für Previews ja/nein.
|
|
||||||
- Ich kann danach ein Security‑Design‑Dokument (nginx rules, env vars, checklist) erstellen oder direkt Implementierungs‑Patches liefern.
|
|
||||||
|
|
||||||
Bitte bestätige kurz die vier entscheidenden Punkte, damit ich das Design final zuspitze:
|
|
||||||
- Hosts: welche Subdomains sollen verwendet werden? (z. B. `deinprojekt.meindomain.de`, `deinprojekt.lan.meindomain.de`)
|
|
||||||
- Management‑UUID extern erlaubt? (Ja/Nein)
|
|
||||||
- Rate‑Limit für public Uploads? (z. B. `20 uploads/IP/Stunde`)
|
|
||||||
- Previews via CDN erlaubt? (Ja/Nein)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Bitte sag mir, ob ich diese detaillierte Sektion so übernehmen soll (ich habe sie bereits in dieses Feature‑Request eingefügt). Wenn ja, kann ich auf Wunsch noch ein kurzes Security‑Design‑PDF oder konkrete nginx‑Snippet‑Dateien ergänzen.
|
|
||||||
|
|
||||||
<!-- Ende Feature Request -->
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
<!--
|
|
||||||
Feature Request: Server-seitige Session-Authentifizierung für Admin-API
|
|
||||||
Zielgruppe: Entwickler / KI-Implementierer
|
|
||||||
-->
|
|
||||||
|
|
||||||
1. erstelle ein Branch namens `feature/security` aus dem aktuellen `main` Branch.
|
|
||||||
2. erstelle eine Datei `FeatureRequests/FEATURE_PLAN-security.md` in der du die Umsetzungsaufgaben dokumentierst (siehe unten) und darin die TODO Liste erstellst und aktuallisierst.
|
|
||||||
3. Stelle mir Fragen bezüglich der Umsetzung
|
|
||||||
4. Verstehe, wie bisher im Frontend die UI aufgebaut ist (modular, keine inline css, globale app.css)
|
|
||||||
5. Implementiere die untenstehenden Aufgaben Schritt für Schritt.
|
|
||||||
|
|
||||||
|
|
||||||
# FEATURE_REQUEST: Security — Server-seitige Sessions für Admin-API
|
|
||||||
|
|
||||||
Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler)
|
|
||||||
|
|
||||||
Die folgenden Aufgaben sind Schritt-für-Schritt auszuführen. Jede Aufgabe enthält das gewünschte Ergebnis und minimalen Beispielcode oder Befehle. Die KI/Entwickler sollen die Änderungen als Code-Patches anlegen, Tests hinzufügen und die Dokumentation aktualisieren.
|
|
||||||
|
|
||||||
1) Session-Store & Session-Konfiguration
|
|
||||||
- Ziel: Server-seitige Sessions für Admin-Login verfügbar machen.
|
|
||||||
- Schritte:
|
|
||||||
- Installiere Packages: `npm install express-session connect-sqlite3 --save` (Backend).
|
|
||||||
- In `backend/src/server.js` (oder Entrypoint) konfiguriere `express-session` mit `connect-sqlite3`:
|
|
||||||
```js
|
|
||||||
const session = require('express-session');
|
|
||||||
const SQLiteStore = require('connect-sqlite3')(session);
|
|
||||||
app.use(session({
|
|
||||||
store: new SQLiteStore({ db: 'sessions.sqlite' }),
|
|
||||||
secret: process.env.ADMIN_SESSION_SECRET,
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', maxAge: 8*60*60*1000 }
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
- Abnahme: Session-Cookie (`sid`) wird gesetzt nach Login, cookie-Flags korrekt.
|
|
||||||
|
|
||||||
2) Login-Endpoint (Admin)
|
|
||||||
- Ziel: Admin kann sich mit Benutzername/Passwort anmelden; Backend erstellt Session.
|
|
||||||
- Schritte:
|
|
||||||
- Füge `POST /auth/login` hinzu, prüft Credentials (z. B. gegen environment-stored admin user/pass oder htpasswd), legt `req.session.user = { role: 'admin' }` an und `req.session.csrfToken = randomHex()` an.
|
|
||||||
- Rückgabe: 200 OK. Cookie wird automatisch gesetzt (`credentials: 'include'` vom Frontend).
|
|
||||||
- Abnahme: Nach `POST /auth/login` existiert `req.session.user` und `req.session.csrfToken`.
|
|
||||||
|
|
||||||
3) CSRF-Endpoint + Middleware
|
|
||||||
- Ziel: Session-gebundenen CSRF-Token ausgeben und Requests schützen.
|
|
||||||
- Schritte:
|
|
||||||
- Endpoint `GET /auth/csrf-token` gibt `{ csrfToken: req.session.csrfToken }` zurück (nur wenn eingeloggt).
|
|
||||||
- Middleware `requireCsrf` prüft `req.headers['x-csrf-token'] === req.session.csrfToken` für state-changing Methoden.
|
|
||||||
- Abnahme: state-changing Admin-Requests ohne oder mit falschem `X-CSRF-Token` bekommen `403`.
|
|
||||||
|
|
||||||
4) Backend-Auth-Middleware für Admin-API
|
|
||||||
- Ziel: Alle `/api/admin/*` Endpoints prüfen Session statt Client-Token.
|
|
||||||
- Schritte:
|
|
||||||
- Ersetze oder erweitere bestehende Admin-Auth-Middleware (`middlewares/auth.js`) so, dass sie `req.session.user && req.session.user.role === 'admin'` prüft; falls nicht gesetzt → `403`.
|
|
||||||
- Abnahme: `GET /api/admin/*` ohne Session → `403`; mit gültiger Session → durchgelassen.
|
|
||||||
|
|
||||||
5) Frontend-Änderungen (adminApi)
|
|
||||||
- Ziel: Frontend sendet keine Admin-Bearer-Tokens mehr; verwendet Cookie-Session + CSRF-Header.
|
|
||||||
- Schritte:
|
|
||||||
- Entferne in `frontend/src/services/adminApi.js` die Abhängigkeit von `process.env.REACT_APP_ADMIN_API_KEY`.
|
|
||||||
- Passe `adminFetch`/`adminRequest` an: bei Requests setze `credentials: 'include'` und füge `X-CSRF-Token` Header (Token bezieht Frontend über `GET /auth/csrf-token` nach Login).
|
|
||||||
- Dokumentiere in `frontend/README` oder Code-Kommentar, dass Admin-UI nach Login `fetch('/auth/csrf-token', { credentials: 'include' })` aufruft.
|
|
||||||
- Abnahme: `adminApi.js` sendet keine Bearer-Header; admin Requests beinhalten `credentials: 'include'` und `X-CSRF-Token`.
|
|
||||||
|
|
||||||
6) Entfernen von Admin-Key aus Frontend Build/Compose/Dockerfile
|
|
||||||
- Ziel: Keine Weitergabe von `ADMIN_API_KEY` an `frontend` und kein Kopieren sensibler `.env` in Frontend-Image.
|
|
||||||
- Schritte:
|
|
||||||
- Entferne Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` aus `docker/prod/docker-compose.yml`.
|
|
||||||
- Entferne `COPY docker/prod/frontend/config/.env ./.env` aus `docker/prod/frontend/Dockerfile` oder stelle sicher, dass diese Datei ausschließlich non-sensitive Keys enthält.
|
|
||||||
- Dokumentiere in `FeatureRequests/FEATURE_REQUEST-security.md` welche Keys im runtime-`env.sh` erlaubt sind (z. B. `API_URL`, `APP_VERSION`).
|
|
||||||
- Abnahme: `docker-compose` enthält keine Übergabe an `frontend`; Build und Image enthalten keine Production-Secrets.
|
|
||||||
|
|
||||||
7) Secrets-Handling / Deployment
|
|
||||||
- Ziel: Secrets nur in Backend-Umgebung bereitstellen.
|
|
||||||
- Schritte:
|
|
||||||
- Setze `ADMIN_API_KEY` und `ADMIN_SESSION_SECRET` in CI/CD Secrets oder Docker Secrets und referenziere sie nur im `backend` Service.
|
|
||||||
- Beispiel-Dokumentation für CI: wie man Secret in GitLab/GitHub Actions setzt und an Container übergibt.
|
|
||||||
- Abnahme: Secrets sind nicht in Repo/Images; `docker inspect` der frontend-Container zeigt keinen Admin-Key.
|
|
||||||
|
|
||||||
8) Tests & CI-Checks
|
|
||||||
- Ziel: Automatisierte Verifikation der Sicherheitsregeln.
|
|
||||||
- Schritte:
|
|
||||||
- Integrationstest 1: `GET /api/admin/some` ohne Session → expect 403.
|
|
||||||
- Integrationstest 2: `POST /auth/login` with admin credentials → expect Set-Cookie; then `GET /auth/csrf-token` → receive token; then `POST /api/admin/action` with `X-CSRF-Token` → expect 200.
|
|
||||||
- Build-scan-Check: CI Schritt `rg REACT_APP_ADMIN_API_KEY build/ || true` fails if found.
|
|
||||||
- Abnahme: Tests grün; CI verweigert Merge wenn Build enthält Admin-Key.
|
|
||||||
|
|
||||||
9) Key-Leak Reaktion (konkrete Anweisungen)
|
|
||||||
- Ziel: Falls ein Admin-Key geleakt wurde, sichere, koordinierte Rotation.
|
|
||||||
- Schritte:
|
|
||||||
- Scannen: `trufflehog --regex --entropy=True .` oder `git-secrets scan`.
|
|
||||||
- Entfernen: `git-filter-repo --replace-text passwords.txt` oder `bfg --replace-text passwords.txt` (siehe docs).
|
|
||||||
- Rotation: Erzeuge neuen Key (openssl rand -hex 32), update CI secret, redeploy Backend.
|
|
||||||
- Hinweis: History-Rewrite ist invasiv; kommuniziere mit Team und informiere Contributors.
|
|
||||||
|
|
||||||
10) Dokumentation
|
|
||||||
- Ziel: Abschlussdokumentation aktualisiert.
|
|
||||||
- Schritte:
|
|
||||||
- Ergänze `AUTHENTICATION.md` um Login/Session/CSRF-Flow und Secret-Handling.
|
|
||||||
- Ergänze `FeatureRequests/FEATURE_REQUEST-security.md` mit Implementations-Links (Patches/PRs).
|
|
||||||
|
|
||||||
11) MIGRATION-GUIDE Anpassung (unbedingt)
|
|
||||||
- Ziel: Die `frontend/MIGRATION-GUIDE.md` spiegelt nicht mehr den sicheren Produktions-Workflow. Sie muss aktualisiert werden, damit Entwickler/KI keine unsicheren Anweisungen (Admin-Key im Frontend) ausführen.
|
|
||||||
- Aktueller Stand (zu prüfen): Die MIGRATION-GUIDE enthält Anweisungen, `REACT_APP_ADMIN_API_KEY` in `frontend/.env` zu setzen und dieselbe Variable an `frontend` im `docker-compose.yml` weiterzugeben. Dies steht im direkten Widerspruch zur hier geforderten serverseitigen Session-Lösung.
|
|
||||||
- Erforderliche Änderungen in `frontend/MIGRATION-GUIDE.md` (konkret):
|
|
||||||
- Entferne oder ersetze alle Anweisungen, die `REACT_APP_ADMIN_API_KEY` in Frontend `.env` oder Build-Umgebungen für Production setzen.
|
|
||||||
- Ersetze Fetch-/Axios-Beispiele, die `Authorization: Bearer ${process.env.REACT_APP_ADMIN_API_KEY}` setzen, durch die neue Anleitung: Login → `GET /auth/csrf-token` → `fetch(..., { credentials: 'include', headers: { 'X-CSRF-Token': csrfToken } })`.
|
|
||||||
- Passe das Docker-Beispiel an: `ADMIN_API_KEY` darf nur dem `backend`-Service übergeben werden; entferne die Weitergabe an `frontend` (Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}`).
|
|
||||||
- Ersetze lokale Testanweisungen, die Frontend mit `REACT_APP_ADMIN_API_KEY` starten, durch Login-/Session-Testschritte (siehe Tasks 2/3/8).
|
|
||||||
- Ergänze Hinweis zur CI/Build-Scan-Prüfung: CI muss prüfen, dass gebaute `build/` keine Admin-Key-Strings enthält.
|
|
||||||
- Abnahme: `frontend/MIGRATION-GUIDE.md` enthält keine Production-Anweisungen, die Admin-Secrets ins Frontend bringen; stattdessen ist der Session-Flow dokumentiert und verlinkt.
|
|
||||||
|
|
||||||
Hinweis für die Implementierung
|
|
||||||
- Ergänze in `FeatureRequests/FEATURE_REQUEST-security.md` einen Link/Verweis zur überarbeiteten MIGRATION-GUIDE-Version in der PR/Release-Notes, damit Reviewer die Änderung nachvollziehen können.
|
|
||||||
|
|
||||||
Rolle der implementierenden KI/Dev
|
|
||||||
- Erzeuge konkrete Code-Patches, führe lokale Tests aus, öffne PR mit Änderungen und Tests.
|
|
||||||
- Stelle sicher, dass alle Abnahme-Kriterien (oben) automatisiert oder manuell prüfbar sind.
|
|
||||||
|
|
||||||
Mit diesen Aufgaben sind die vorher offenen Fragen in eindeutige, ausführbare Schritte übersetzt. Bitte bestätige, welche Aufgaben ich automatisch umsetzen soll (z. B. `1` = Compose/Docker-Änderungen; `2` = Frontend `adminApi.js` Patch; `3` = Backend Session+CSRF minimal-Implementierung; oder `all`).
|
|
||||||
|
|
||||||
Hintergrund (Ist-Stand)
|
|
||||||
- Aktuell existieren folgende sicherheitskritische Zustände im Repository:
|
|
||||||
- `frontend/.env` enthält `REACT_APP_ADMIN_API_KEY` in der Arbeitskopie (lokal). Die Datei ist in `.gitignore` und wird nicht ins Git-Repository getrackt, ist aber sensibel und darf nicht in Builds/Images gelangen.
|
|
||||||
- `docker/prod/docker-compose.yml` injiziert `REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` in den `frontend`-Service — der Key kann so in den gebauten Frontend-Bundles landen.
|
|
||||||
- `frontend/src/services/adminApi.js` liest `process.env.REACT_APP_ADMIN_API_KEY` und sendet den Bearer-Token clientseitig mit Admin-Requests.
|
|
||||||
- Das Production-Frontend-Dockerfile kopiert `docker/prod/frontend/config/.env` in das Laufzeit-Image und führt zur Startzeit ein `env.sh` aus, das `env-config.js` erzeugt (`window._env_`), was sensible Werte im Browser verfügbar machen kann, falls sie in `.env` landen.
|
|
||||||
- Die Moderation-Weboberfläche ist zusätzlich durch `htpasswd`/nginx HTTP Basic Auth geschützt — das schützt das UI, aber nicht die API-Endpoints ausreichend.
|
|
||||||
|
|
||||||
Problemstellung (warum es ein Problem ist)
|
|
||||||
- Ein im Frontend sichtbarer Admin-Key ist öffentlich und ermöglicht Missbrauch (API-Calls mit Admin-Rechten von jedem Browser).
|
|
||||||
- Das serverseitige Secret `ADMIN_API_KEY` wird derzeit in Artefakte/Images injiziert und kann geleakt werden.
|
|
||||||
- HTTP Basic Auth vor der UI ist nützlich, aber kein Ersatz für serverseitige API-Authentifizierung; API-Endpunkte müssen eigenständig prüfen.
|
|
||||||
|
|
||||||
Ziel (Soll-Stand, aus Kundensicht)
|
|
||||||
- Admin-Funktionen sind nur nach sicherer Anmeldung erreichbar.
|
|
||||||
- Der geheime Admin-Key verbleibt ausschließlich auf dem Server/Backend und wird nicht in Frontend-Code, Images oder öffentlich zugängliche Dateien geschrieben.
|
|
||||||
- Frontend kommuniziert nach Anmeldung mit dem Backend, ohne je den Admin-Key im Browser zu speichern.
|
|
||||||
|
|
||||||
Anforderungen (aus Sicht des Auftraggebers, umsetzbar durch eine KI)
|
|
||||||
- Authentifizierung:
|
|
||||||
- Einführung eines serverseitigen Login-Flows für Admins (Session-Cookies, HttpOnly, Secure, SameSite).
|
|
||||||
- Nach erfolgreicher Anmeldung erhält der Admin-Browser ein HttpOnly-Cookie; dieses Cookie erlaubt Zugriff auf geschützte `/api/admin/*`-Endpoints.
|
|
||||||
- Backend validiert alle `/api/admin/*`-Requests anhand der Session; nur dann wird mit dem internen `ADMIN_API_KEY` gearbeitet.
|
|
||||||
|
|
||||||
- Secrets & Build:
|
|
||||||
- Keine Secrets (z. B. `ADMIN_API_KEY`) im Frontend-Quellcode, in `frontend/.env`, in `env-config.js` oder in gebauten Bundles.
|
|
||||||
- `docker/prod/docker-compose.yml` darf `ADMIN_API_KEY` nur dem `backend`-Service bereitstellen; keine Weitergabe an `frontend`.
|
|
||||||
- `Dockerfile` des Frontends darf keine Produktion-`.env` kopieren, die Secrets enthält.
|
|
||||||
|
|
||||||
- Betrieb & Infrastruktur:
|
|
||||||
- Bestehende `htpasswd`-Absicherung der Admin-UI kann beibehalten werden als zusätzliche Hürde, ist aber nicht die einzige Schutzmaßnahme.
|
|
||||||
- Empfehlung: `ADMIN_API_KEY` über sichere Secret-Mechanismen bereitstellen (CI/CD secret store, Docker Secrets, Swarm/K8s Secrets) — dies ist ein Hinweis, keine Pflichtanweisung.
|
|
||||||
|
|
||||||
Akzeptanzkriterien (klar messbar, für Tests durch eine KI/Dev)
|
|
||||||
- Funktional:
|
|
||||||
- Unauthentifizierte Requests an `/api/admin/*` erhalten `403 Forbidden`.
|
|
||||||
- Admin-Login-Endpoint existiert und setzt ein HttpOnly-Cookie; angemeldete Admins erreichen `/api/admin/*` erfolgreich.
|
|
||||||
|
|
||||||
- Artefakte / Repo:
|
|
||||||
- `frontend`-Bundle (der gebaute `build/`-Ordner) enthält nicht den Wert von `ADMIN_API_KEY` (automatischer Scan: kein Vorkommen des Key-Strings).
|
|
||||||
- `frontend/.env` enthält keine `REACT_APP_ADMIN_API_KEY`-Zeile in Produktion; `docker/prod/docker-compose.yml` enthält keine Weitergabe des Keys an `frontend`.
|
|
||||||
|
|
||||||
- Sicherheit & Ops:
|
|
||||||
- Dokumentation: In `AUTHENTICATION.md` und in dieser Feature-Request-Datei wird der neue Login-Flow und Hinweis zum Secret-Handling vermerkt.
|
|
||||||
- Dokumentation: In `AUTHENTICATION.md` und in dieser Feature-Request-Datei wird der neue Login-Flow und Hinweis zum Secret-Handling vermerkt.
|
|
||||||
- Falls ein Key im Git-Verlauf existierte, ist die Rotation des Admin-Keys als Handlungsempfehlung dokumentiert.
|
|
||||||
- Falls `frontend/.env` oder ein Admin-Key jemals in das Repository gelangt ist: Scannt die Git-History und entfernt das Secret aus der History, danach rotiert den Key. Empfohlene Tools/Schritte (kurz):
|
|
||||||
- Finden: `git log --all -S 'part-of-key'` oder `git grep -n "REACT_APP_ADMIN_API_KEY" $(git rev-list --all)` oder nutzen `truffleHog`/`git-secrets`.
|
|
||||||
- Entfernen aus History: `git-filter-repo` oder `bfg-repo-cleaner` (z.B. `bfg --replace-text passwords.txt --no-blob-protection`) — danach Force-Push in ein neues Remote (Achtung: Auswirkungen auf Contributors).
|
|
||||||
- Key-Rotation: Erzeuge neuen `ADMIN_API_KEY`, setze ihn in der sicheren Backend-Umgebung (CI/CD secrets / Docker secret), redeploye Backend.
|
|
||||||
- Hinweis: Diese Schritte sind invasiv für die Git-History; koordinieren mit Team bevor Ausführung.
|
|
||||||
|
|
||||||
Nicht-funktionale Anforderungen
|
|
||||||
- Use Session-Cookies: Cookies müssen `HttpOnly`, `Secure` und `SameSite=Strict` (oder Lax falls nötig) gesetzt werden.
|
|
||||||
- CSRF-Schutz: Bei Cookie-basierten Sessions muss ein CSRF-Schutzmechanismus vorhanden sein (z. B. double-submit-token oder CSRF-Header). Hinweis: CSRF-Mechanik ist zu implementieren, aber detaillierte Schritte sind nicht Teil dieses Requests.
|
|
||||||
- Kompatibilität: Änderungen dürfen Entwickler-Workflows nicht unnötig blockieren; Dev-Mode-Patterns (runtime `env-config.js` in `docker/dev`) können bestehen bleiben, jedoch klar getrennt von Prod.
|
|
||||||
|
|
||||||
Hinweise für die implementierende KI / das Dev-Team (kontextbezogen)
|
|
||||||
- Aktueller Code-Pfade von Relevanz:
|
|
||||||
- `frontend/src/services/adminApi.js` — liest aktuell `process.env.REACT_APP_ADMIN_API_KEY` und setzt den Bearer-Token clientseitig.
|
|
||||||
- `frontend/.env` — enthält aktuell `REACT_APP_ADMIN_API_KEY`.
|
|
||||||
- `docker/prod/docker-compose.yml` — injiziert `REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` in `frontend`.
|
|
||||||
- `docker/prod/frontend/Dockerfile` — kopiert `docker/prod/frontend/config/.env` in das Image und führt `env.sh` aus, das `env-config.js` erzeugt (`window._env_`).
|
|
||||||
- `docker/prod/frontend/config/env.sh` — generiert zur Laufzeit `env-config.js` aus `.env`.
|
|
||||||
- `docker/prod/frontend/config/htpasswd` — existierender Schutz der Admin-UI via nginx.
|
|
||||||
|
|
||||||
- Erwartung an eine KI-Implementierung:
|
|
||||||
- Verstehe die Codebasis (insbesondere `frontend/src/*` und `backend/src/*`) und identifiziere alle Stellen, die `REACT_APP_ADMIN_API_KEY` oder `ADMIN_API_KEY` verwenden oder weiterreichen.
|
|
||||||
- Entferne clientseitige Verwendung des Admin-Keys; ersetze Aufrufe an Admin-API so, dass sie serverseitig autorisiert werden (Session-Check).
|
|
||||||
- Verifiziere durch automatische Tests (Integrationstest oder API-Call) dass `/api/admin/*` ohne Session abgewiesen wird und mit Session funktioniert.
|
|
||||||
|
|
||||||
Was der Auftraggeber (Ich) erwartet — kurz und klar
|
|
||||||
- Die Admin-Funktionen sind nur nach Anmeldung verfügbar.
|
|
||||||
- Keine Admin-Secrets gelangen in Frontend-Bundles, Images oder öffentlich zugängliche Dateien.
|
|
||||||
- Der existierende `htpasswd`-Schutz darf bestehen bleiben, ist aber nicht alleinige Sicherheitsmaßnahme.
|
|
||||||
|
|
||||||
Abnahmekriterien (für das Review durch den Auftraggeber)
|
|
||||||
- Manuelle Überprüfung: Versuch, Admin-Endpoints ohne Login aufzurufen → `403`.
|
|
||||||
- Build-Review: gebaute Frontend-Dateien enthalten keinen Admin-Key.
|
|
||||||
- Dokumentation aktualisiert (`AUTHENTICATION.md` weist auf neue Session-Flow hin).
|
|
||||||
|
|
||||||
Offene Fragen / Optionen (für Entwickler/KI) — Empfehlung und Umsetzungsdetails
|
|
||||||
|
|
||||||
- Session-Store (empfohlen: SQLite für Single-Host, Redis für Skalierung)
|
|
||||||
- Empfehlung für diese App: **SQLite / file-basierter Session-Store** (einfach zu betreiben, keine zusätzliche Infrastruktur).
|
|
||||||
- Umsetzung (Express): benutze `express-session` + `connect-sqlite3` (oder `better-sqlite3` backend). Konfiguration:
|
|
||||||
- Session-Cookie: `HttpOnly`, `Secure` (Prod), `SameSite=Strict` (oder `Lax` wenn externe callbacks nötig), `maxAge` angemessen (z. B. 8h).
|
|
||||||
- Session-Secret aus sicherer Quelle (Backend `ADMIN_SESSION_SECRET`), nicht im Repo.
|
|
||||||
- Skalierung: falls Cluster/Multiple hosts geplant, wechsle zu **Redis** (z.B. `connect-redis`) und setze Redis via Docker/K8s Secret.
|
|
||||||
|
|
||||||
- CSRF-Mechanik (empfohlen: session-bound CSRF token + Header)
|
|
||||||
- Empfehlung: Implementiere einen session-gebundenen CSRF-Token. Ablauf:
|
|
||||||
1. Bei Login: generiere `req.session.csrfToken = randomHex()` auf dem Server.
|
|
||||||
2. Exponiere Endpoint `GET /auth/csrf-token` (nur für eingeloggte Sessions), der das Token im JSON zurückgibt.
|
|
||||||
3. Frontend ruft `/auth/csrf-token` nach Login (`credentials: 'include'`) und speichert Token im JS-Scope.
|
|
||||||
4. Bei state-changing Requests sendet Frontend `X-CSRF-Token: <token>` Header.
|
|
||||||
5. Server-Middleware vergleicht Header mit `req.session.csrfToken` und verwirft bei Mismatch (403).
|
|
||||||
- Vorteil: HttpOnly-Session-Cookie bleibt geschützt; Token ist an Session gebunden.
|
|
||||||
- Alternative (schnell): Double-submit cookie (weniger robust, Token in non-HttpOnly cookie + Header); nur als kurzfristige Übergangslösung.
|
|
||||||
|
|
||||||
- Entfernen von Admin-Key aus Frontend/Build (konkrete Änderungen)
|
|
||||||
- `frontend/src/services/adminApi.js`: entferne Nutzung von `process.env.REACT_APP_ADMIN_API_KEY`. Passe `adminRequest` so an, dass `credentials: 'include'` verwendet wird und kein Bearer-Token gesetzt wird.
|
|
||||||
- `docker/prod/docker-compose.yml`: lösche die Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` unter `frontend`.
|
|
||||||
- `docker/prod/frontend/Dockerfile`: entferne `COPY docker/prod/frontend/config/.env ./.env` (oder stelle sicher, dass die Datei keine Secrets enthält). Vermeide Prod `.env` Kopie in Image.
|
|
||||||
- `docker/prod/frontend/config/env.sh`: darf nur non-sensitive Werte (z. B. `API_URL`, `APP_VERSION`) schreiben; dokumentiere welche Keys erlaubt sind.
|
|
||||||
|
|
||||||
- Secrets-Delivery / Deployment
|
|
||||||
- Backend-Secret `ADMIN_API_KEY` und `ADMIN_SESSION_SECRET` via CI/CD Secret Store oder Docker Secrets bereitstellen.
|
|
||||||
- Beispiel (Docker secret): erstelle Secret im Swarm/K8s und referenziere es nur im `backend`-Service.
|
|
||||||
|
|
||||||
- Tests / Abnahme (automatisierbar)
|
|
||||||
- Integrationstest 1: `GET /api/admin/*` ohne Session → `403`.
|
|
||||||
- Integrationstest 2: Login-Flow → setze Session-Cookie → `POST /api/admin/*` mit gültigem `X-CSRF-Token` → `200`.
|
|
||||||
- Build-Scan: nach Build `grep -R "<part-of-admin-key>" build/` oder `rg REACT_APP_ADMIN_API_KEY build/` → kein Treffer.
|
|
||||||
|
|
||||||
- Key-Leak Reaktion
|
|
||||||
- Falls Key geleakt: sofort Key-Rotation, dokumentiere wie (generate new key, update backend secret, redeploy), und führe Git-History-Check wie in Abschnitt oben durch.
|
|
||||||
|
|
||||||
- Operationale Hinweise
|
|
||||||
- Behalte `htpasswd`-Schutz für Admin-UI als zusätzliche Zugangskontrolle, nicht als einzige Schutzmaßnahme.
|
|
||||||
- Dokumentiere alle Änderungen in `AUTHENTICATION.md` und in Release-Notes.
|
|
||||||
|
|
||||||
Diese Empfehlungen sind aus Sicht einer sicheren, pragmatischen Umsetzung gewählt: sie minimieren Infrastruktur-Overhead (SQLite) und bieten klare, testbare CSRF/Session‑Mechaniken. Eine implementierende KI soll die obenstehenden Punkte in konkreten Code‑Patches umsetzen und automatisierte Tests hinzufügen.
|
|
||||||
|
|
||||||
Kontakt / Kontext
|
|
||||||
- Repo: Project-Image-Uploader — bestehende Struktur `docker/prod`, `frontend/`, `backend/` beachten.
|
|
||||||
- Hinweis: Diese Feature-Request-Datei benennt Missstände und Anforderungen; sie erwartet, dass ein Entwickler oder eine KI die Implementierung auf Code-Ebene vornimmt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
# Feature Request: Telegram Bot für Benachrichtigungen
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Integration eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
|
|
||||||
|
|
||||||
## Ziel
|
|
||||||
|
|
||||||
Werkstatt-Mitarbeiter sollen zeitnah über neue Uploads, Änderungen und bevorstehende Löschungen informiert werden, ohne ständig das Admin-Panel prüfen zu müssen.
|
|
||||||
|
|
||||||
## Use Case
|
|
||||||
|
|
||||||
Die Offene Werkstatt hat eine Telegram Gruppe, in der das Team kommuniziert. Der Bot wird zu dieser Gruppe hinzugefügt und sendet automatisierte Benachrichtigungen bei relevanten Events.
|
|
||||||
|
|
||||||
## Funktionale Anforderungen
|
|
||||||
|
|
||||||
### 1. Benachrichtigung: Neuer Upload
|
|
||||||
|
|
||||||
**Trigger:** Erfolgreicher Batch-Upload über `/api/upload-batch`
|
|
||||||
|
|
||||||
**Nachricht enthält:**
|
|
||||||
- 📸 Upload-Icon
|
|
||||||
- Name des Uploaders
|
|
||||||
- Anzahl der hochgeladenen Bilder
|
|
||||||
- Jahr der Gruppe
|
|
||||||
- Titel der Gruppe
|
|
||||||
- Workshop-Consent Status (✅ Ja / ❌ Nein)
|
|
||||||
- Social Media Consents (Facebook, Instagram, TikTok Icons)
|
|
||||||
- Link zum Admin-Panel (Moderation)
|
|
||||||
|
|
||||||
**Beispiel:**
|
|
||||||
```
|
|
||||||
📸 Neuer Upload!
|
|
||||||
|
|
||||||
Uploader: Max Mustermann
|
|
||||||
Bilder: 12
|
|
||||||
Gruppe: 2024 - Schweißkurs November
|
|
||||||
Workshop: ✅ Ja
|
|
||||||
Social Media: 📘 Instagram, 🎵 TikTok
|
|
||||||
|
|
||||||
🔗 Zur Freigabe: https://internal.hobbyhimmel.de/moderation
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Benachrichtigung: User-Änderungen
|
|
||||||
|
|
||||||
**Trigger:**
|
|
||||||
- `PUT /api/manage/:token` (Consent-Änderung)
|
|
||||||
- `DELETE /api/manage/:token/groups/:groupId` (Gruppenl löschung durch User)
|
|
||||||
|
|
||||||
**Nachricht enthält:**
|
|
||||||
- ⚙️ Änderungs-Icon
|
|
||||||
- Art der Änderung (Consent Update / Gruppe gelöscht)
|
|
||||||
- Betroffene Gruppe (Jahr + Titel)
|
|
||||||
- Uploader-Name
|
|
||||||
- Neue Consent-Werte (bei Update)
|
|
||||||
|
|
||||||
**Beispiel (Consent-Änderung):**
|
|
||||||
```
|
|
||||||
⚙️ User-Änderung
|
|
||||||
|
|
||||||
Aktion: Consent aktualisiert
|
|
||||||
Gruppe: 2024 - Schweißkurs November
|
|
||||||
Uploader: Max Mustermann
|
|
||||||
|
|
||||||
Neu:
|
|
||||||
Workshop: ❌ Nein (vorher: ✅)
|
|
||||||
Social Media: 📘 Instagram (TikTok entfernt)
|
|
||||||
|
|
||||||
🔗 Details: https://internal.hobbyhimmel.de/moderation
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beispiel (Gruppe gelöscht):**
|
|
||||||
```
|
|
||||||
⚙️ User-Änderung
|
|
||||||
|
|
||||||
Aktion: Gruppe gelöscht
|
|
||||||
Gruppe: 2024 - Schweißkurs November
|
|
||||||
Uploader: Max Mustermann
|
|
||||||
Bilder: 12
|
|
||||||
|
|
||||||
ℹ️ User hat Gruppe selbst über Management-Link gelöscht
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Benachrichtigung: Ablauf Freigabe / Löschung in 1 Tag
|
|
||||||
|
|
||||||
**Trigger:** Täglicher Cron-Job (z.B. 09:00 Uhr)
|
|
||||||
|
|
||||||
**Prüfung:**
|
|
||||||
- Alle nicht-freigegebenen Gruppen mit `created_at < NOW() - 6 days`
|
|
||||||
- Werden in 24 Stunden durch Cleanup-Service gelöscht
|
|
||||||
|
|
||||||
**Nachricht enthält:**
|
|
||||||
- ⏰ Warnung-Icon
|
|
||||||
- Liste aller betroffenen Gruppen
|
|
||||||
- Countdown bis Löschung
|
|
||||||
- Hinweis auf Freigabe-Möglichkeit
|
|
||||||
|
|
||||||
**Beispiel:**
|
|
||||||
```
|
|
||||||
⏰ Löschung in 24 Stunden!
|
|
||||||
|
|
||||||
Folgende Gruppen werden morgen automatisch gelöscht:
|
|
||||||
|
|
||||||
1. 2024 - Schweißkurs November
|
|
||||||
Uploader: Max Mustermann
|
|
||||||
Bilder: 12
|
|
||||||
Hochgeladen: 20.11.2024
|
|
||||||
|
|
||||||
2. 2024 - Holzarbeiten Workshop
|
|
||||||
Uploader: Anna Schmidt
|
|
||||||
Bilder: 8
|
|
||||||
Hochgeladen: 21.11.2024
|
|
||||||
|
|
||||||
💡 Jetzt freigeben oder Freigabe bleibt aus!
|
|
||||||
🔗 Zur Moderation: https://internal.hobbyhimmel.de/moderation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technische Anforderungen
|
|
||||||
|
|
||||||
### Backend-Integration
|
|
||||||
|
|
||||||
**Neue Umgebungsvariablen:**
|
|
||||||
```bash
|
|
||||||
TELEGRAM_BOT_TOKEN=<bot-token>
|
|
||||||
TELEGRAM_CHAT_ID=<werkstatt-gruppen-id>
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Neue Service-Datei:** `backend/src/services/TelegramNotificationService.js`
|
|
||||||
|
|
||||||
**Methoden:**
|
|
||||||
- `sendUploadNotification(groupData)`
|
|
||||||
- `sendConsentChangeNotification(oldConsents, newConsents, groupData)`
|
|
||||||
- `sendGroupDeletedNotification(groupData)`
|
|
||||||
- `sendDeletionWarning(groupsList)`
|
|
||||||
|
|
||||||
**Integration Points:**
|
|
||||||
- `routes/batchUpload.js` → Nach erfolgreichem Upload
|
|
||||||
- `routes/management.js` → PUT/DELETE Endpoints
|
|
||||||
- `services/GroupCleanupService.js` → Neue Methode für tägliche Prüfung
|
|
||||||
|
|
||||||
### Telegram Bot Setup
|
|
||||||
|
|
||||||
**Bot erstellen:**
|
|
||||||
1. Mit [@BotFather](https://t.me/botfather) sprechen
|
|
||||||
2. `/newbot` → Bot-Name: "Werkstatt Image Uploader Bot"
|
|
||||||
3. Token speichern → `.env`
|
|
||||||
|
|
||||||
**Bot zur Gruppe hinzufügen:**
|
|
||||||
1. Bot zu Werkstatt-Gruppe einladen
|
|
||||||
2. Chat-ID ermitteln: `https://api.telegram.org/bot<TOKEN>/getUpdates`
|
|
||||||
3. Chat-ID speichern → `.env`
|
|
||||||
|
|
||||||
**Berechtigungen:**
|
|
||||||
- ✅ Can send messages
|
|
||||||
- ✅ Can send photos (optional, für Vorschau-Bilder)
|
|
||||||
- ❌ Keine Admin-Rechte nötig
|
|
||||||
|
|
||||||
### Cron-Job für tägliche Prüfung
|
|
||||||
|
|
||||||
**Optionen:**
|
|
||||||
|
|
||||||
**A) Node-Cron (empfohlen für Development):**
|
|
||||||
```javascript
|
|
||||||
// backend/src/services/TelegramScheduler.js
|
|
||||||
const cron = require('node-cron');
|
|
||||||
|
|
||||||
// Jeden Tag um 09:00 Uhr
|
|
||||||
cron.schedule('0 9 * * *', async () => {
|
|
||||||
await checkPendingDeletions();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**B) System Cron (Production):**
|
|
||||||
```bash
|
|
||||||
# crontab -e
|
|
||||||
0 9 * * * curl -X POST http://localhost:5000/api/admin/telegram/check-deletions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Neue Route:** `POST /api/admin/telegram/check-deletions` (Admin-Auth)
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
**Neue NPM Packages:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
|
||||||
"node-cron": "^3.0.3"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Development (.env)
|
|
||||||
```bash
|
|
||||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
||||||
TELEGRAM_CHAT_ID=-1001234567890
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
TELEGRAM_DAILY_CHECK_TIME=09:00
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
- Gleiche Variablen in `docker/prod/backend/config/.env`
|
|
||||||
- Cron-Job via Node-Cron oder System-Cron
|
|
||||||
|
|
||||||
## Sicherheit
|
|
||||||
|
|
||||||
- ✅ Bot-Token niemals committen (`.env` nur)
|
|
||||||
- ✅ Chat-ID validieren (nur bekannte Gruppen)
|
|
||||||
- ✅ Keine sensiblen Daten in Nachrichten (keine Email, keine vollständigen Token)
|
|
||||||
- ✅ Rate-Limiting für Telegram API (max 30 msg/sec)
|
|
||||||
- ✅ Error-Handling: Wenn Telegram down → Upload funktioniert trotzdem
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Manuell:**
|
|
||||||
```bash
|
|
||||||
# Trigger Upload-Benachrichtigung
|
|
||||||
curl -X POST http://localhost:5001/api/upload-batch \
|
|
||||||
-F "images=@test.jpg" \
|
|
||||||
-F "year=2024" \
|
|
||||||
-F "title=Test Upload" \
|
|
||||||
-F "name=Test User" \
|
|
||||||
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
|
|
||||||
|
|
||||||
# Trigger Consent-Änderung
|
|
||||||
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
|
|
||||||
|
|
||||||
# Trigger tägliche Prüfung (Admin)
|
|
||||||
curl -X POST http://localhost:5001/api/admin/telegram/check-deletions \
|
|
||||||
-b cookies.txt -H "X-CSRF-Token: $CSRF"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Automatisiert:**
|
|
||||||
- Unit-Tests für `TelegramNotificationService.js`
|
|
||||||
- Mock Telegram API mit `nock`
|
|
||||||
- Prüfe Nachrichtenformat + Escaping
|
|
||||||
|
|
||||||
## Optional: Zukünftige Erweiterungen
|
|
||||||
|
|
||||||
- 📊 Wöchentlicher Statistik-Report (Uploads, Freigaben, Löschungen)
|
|
||||||
- 🖼️ Preview-Bild im Telegram (erstes Bild der Gruppe)
|
|
||||||
- 💬 Interaktive Buttons (z.B. "Freigeben", "Ablehnen") → Webhook
|
|
||||||
- 🔔 Admin-Befehle (`/stats`, `/pending`, `/cleanup`)
|
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
|
||||||
|
|
||||||
- [ ] Bot sendet Nachricht bei neuem Upload
|
|
||||||
- [ ] Bot sendet Nachricht bei Consent-Änderung
|
|
||||||
- [ ] Bot sendet Nachricht bei User-Löschung
|
|
||||||
- [ ] Bot sendet tägliche Warnung für bevorstehende Löschungen (09:00 Uhr)
|
|
||||||
- [ ] Alle Nachrichten enthalten relevante Informationen + Link
|
|
||||||
- [ ] Telegram-Fehler brechen Upload/Änderungen nicht ab
|
|
||||||
- [ ] ENV-Variable `TELEGRAM_ENABLED=false` deaktiviert alle Benachrichtigungen
|
|
||||||
- [ ] README.dev.md enthält Setup-Anleitung
|
|
||||||
|
|
||||||
## Aufwandsschätzung
|
|
||||||
|
|
||||||
- Backend-Integration: ~4-6 Stunden
|
|
||||||
- Cron-Job Setup: ~2 Stunden
|
|
||||||
- Testing: ~2 Stunden
|
|
||||||
- Dokumentation: ~1 Stunde
|
|
||||||
|
|
||||||
**Gesamt: ~9-11 Stunden**
|
|
||||||
|
|
||||||
## Priorität
|
|
||||||
|
|
||||||
**Medium** - Verbessert Workflow, aber nicht kritisch für Kernfunktion
|
|
||||||
|
|
||||||
## Release-Planung
|
|
||||||
|
|
||||||
**Target Version:** `2.0.0` (Major Version)
|
|
||||||
|
|
||||||
**Begründung für Major Release:**
|
|
||||||
- Neue Infrastruktur-Abhängigkeit (Telegram Bot)
|
|
||||||
- Neue Umgebungsvariablen erforderlich
|
|
||||||
- Breaking Change: Optional, aber empfohlene Konfiguration
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### 1. Feature Branch erstellen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/telegram-notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Conventional Commits verwenden
|
|
||||||
|
|
||||||
**Wichtig:** Alle Commits nach [Conventional Commits](https://www.conventionalcommits.org/) formatieren!
|
|
||||||
|
|
||||||
**Beispiele:**
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: Add TelegramNotificationService"
|
|
||||||
git commit -m "feat: Add upload notification endpoint"
|
|
||||||
git commit -m "feat: Add daily deletion warning cron job"
|
|
||||||
git commit -m "chore: Add node-telegram-bot-api dependency"
|
|
||||||
git commit -m "docs: Update README with Telegram setup"
|
|
||||||
git commit -m "test: Add TelegramNotificationService unit tests"
|
|
||||||
git commit -m "fix: Handle Telegram API rate limiting"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit-Typen:**
|
|
||||||
- `feat:` - Neue Features
|
|
||||||
- `fix:` - Bugfixes
|
|
||||||
- `docs:` - Dokumentation
|
|
||||||
- `test:` - Tests
|
|
||||||
- `chore:` - Dependencies, Config
|
|
||||||
- `refactor:` - Code-Umstrukturierung
|
|
||||||
|
|
||||||
→ **Wird automatisch im CHANGELOG.md gruppiert!**
|
|
||||||
|
|
||||||
### 3. Development Setup
|
|
||||||
|
|
||||||
**Docker Dev Environment nutzen:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Container starten
|
|
||||||
./dev.sh
|
|
||||||
|
|
||||||
# .env konfigurieren (Backend)
|
|
||||||
# docker/dev/backend/config/.env
|
|
||||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
||||||
TELEGRAM_CHAT_ID=-1001234567890
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
TELEGRAM_DAILY_CHECK_TIME=09:00
|
|
||||||
|
|
||||||
# Backend neu starten (lädt neue ENV-Variablen)
|
|
||||||
docker compose -f docker/dev/docker-compose.yml restart backend-dev
|
|
||||||
|
|
||||||
# Logs verfolgen
|
|
||||||
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests ausführen:**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm test -- tests/unit/TelegramNotificationService.test.js
|
|
||||||
npm test -- tests/api/telegram.test.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Dokumentation aktualisieren
|
|
||||||
|
|
||||||
**README.md** - User-Dokumentation ergänzen:
|
|
||||||
- [ ] Telegram-Bot Setup-Anleitung
|
|
||||||
- [ ] Benachrichtigungs-Features beschreiben
|
|
||||||
- [ ] ENV-Variablen dokumentieren
|
|
||||||
|
|
||||||
**README.dev.md** - Development-Doku ergänzen:
|
|
||||||
- [ ] Telegram-Bot Testing-Anleitung
|
|
||||||
- [ ] Cron-Job Debugging
|
|
||||||
- [ ] TelegramNotificationService API-Referenz
|
|
||||||
- [ ] Beispiel-Curl-Commands für manuelle Trigger
|
|
||||||
|
|
||||||
**Sektion in README.dev.md einfügen (z.B. nach "Cleanup-System testen"):**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### Telegram-Benachrichtigungen testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Bot-Token validieren:
|
|
||||||
curl https://api.telegram.org/bot<TOKEN>/getMe
|
|
||||||
|
|
||||||
# Chat-ID ermitteln:
|
|
||||||
curl https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
||||||
|
|
||||||
# Upload-Benachrichtigung testen:
|
|
||||||
# → Einfach Upload durchführen, Telegram-Gruppe prüfen
|
|
||||||
|
|
||||||
# Consent-Änderung testen:
|
|
||||||
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
|
|
||||||
|
|
||||||
# Tägliche Löschwarnung manuell triggern:
|
|
||||||
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
|
||||||
-X POST http://localhost:5001/api/admin/telegram/check-deletions
|
|
||||||
```
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 5. Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Unit-Tests für `TelegramNotificationService.js` (min. 80% Coverage)
|
|
||||||
- [ ] Integration-Tests für alle 3 Benachrichtigungstypen
|
|
||||||
- [ ] Manueller Test: Upload → Telegram-Nachricht kommt an
|
|
||||||
- [ ] Manueller Test: Consent-Änderung → Telegram-Nachricht kommt an
|
|
||||||
- [ ] Manueller Test: User-Löschung → Telegram-Nachricht kommt an
|
|
||||||
- [ ] Manueller Test: Cron-Job (tägliche Warnung) funktioniert
|
|
||||||
- [ ] Error-Handling: Telegram down → Upload funktioniert trotzdem
|
|
||||||
- [ ] ENV `TELEGRAM_ENABLED=false` → Keine Nachrichten
|
|
||||||
|
|
||||||
### 6. Release erstellen
|
|
||||||
|
|
||||||
**Nach erfolgreicher Implementierung:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Alle Änderungen committen (Conventional Commits!)
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: Complete Telegram notification system"
|
|
||||||
|
|
||||||
# Feature Branch pushen
|
|
||||||
git push origin feature/telegram-notifications
|
|
||||||
|
|
||||||
# Merge in main (nach Review)
|
|
||||||
git checkout main
|
|
||||||
git merge feature/telegram-notifications
|
|
||||||
|
|
||||||
# Major Release erstellen (2.0.0)
|
|
||||||
npm run release:major
|
|
||||||
|
|
||||||
# CHANGELOG prüfen (wurde automatisch generiert!)
|
|
||||||
cat CHANGELOG.md
|
|
||||||
|
|
||||||
# Push mit Tags
|
|
||||||
git push --follow-tags
|
|
||||||
|
|
||||||
# Docker Images bauen und pushen
|
|
||||||
./prod.sh # Option 3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Release Notes (automatisch in CHANGELOG.md):**
|
|
||||||
- ✨ Features: Telegram-Bot Integration (Upload, Änderungen, Lösch-Warnungen)
|
|
||||||
- 📚 Documentation: README.md + README.dev.md Updates
|
|
||||||
- 🧪 Tests: TelegramNotificationService Tests
|
|
||||||
|
|
||||||
### 7. Deployment
|
|
||||||
|
|
||||||
**Production .env updaten:**
|
|
||||||
```bash
|
|
||||||
# docker/prod/backend/config/.env
|
|
||||||
TELEGRAM_BOT_TOKEN=<production-token>
|
|
||||||
TELEGRAM_CHAT_ID=<production-chat-id>
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Container neu starten:**
|
|
||||||
```bash
|
|
||||||
./prod.sh # Option 4: Container neu bauen und starten
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wichtige Hinweise
|
|
||||||
|
|
||||||
⚠️ **Vor dem Release prüfen:**
|
|
||||||
- README.md enthält User-Setup-Anleitung
|
|
||||||
- README.dev.md enthält Developer-Anleitung
|
|
||||||
- Alle Tests bestehen (`npm test`)
|
|
||||||
- Docker Dev Setup funktioniert
|
|
||||||
- Conventional Commits verwendet
|
|
||||||
- CHANGELOG.md ist korrekt generiert
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
# 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.
|
|
||||||
307
README.dev.md
307
README.dev.md
|
|
@ -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**: Admin-Endpoints laufen jetzt über serverseitige Sessions + CSRF Tokens
|
- **Authentication**: Alle Admin-Endpoints benötigen jetzt Bearer Token
|
||||||
- **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)
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-
|
||||||
- **`backend/src/routes/README.md`** - Vollständige API-Route-Dokumentation
|
- **`backend/src/routes/README.md`** - Vollständige API-Route-Dokumentation
|
||||||
- **`AUTHENTICATION.md`** - Auth-System-Setup und Verwendung
|
- **`AUTHENTICATION.md`** - Auth-System-Setup und Verwendung
|
||||||
|
|
||||||
|
**Geschätzter Migrations-Aufwand**: 2-3 Stunden
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -33,9 +34,9 @@ docker compose -f docker/dev/docker-compose.yml up -d
|
||||||
### Zugriff
|
### Zugriff
|
||||||
- **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv)
|
- **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv)
|
||||||
- **Backend**: http://localhost:5001 (API)
|
- **Backend**: http://localhost:5001 (API)
|
||||||
- **API Documentation**: http://localhost:5001/api/docs/ (Swagger UI, nur in Development verfügbar)
|
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
|
||||||
- **Slideshow**: http://localhost:3000/slideshow
|
- **Slideshow**: http://localhost:3000/slideshow
|
||||||
- **Moderation**: http://localhost:3000/moderation (Login über Admin Session)
|
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
|
||||||
|
|
||||||
### Logs verfolgen
|
### Logs verfolgen
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -54,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:**
|
||||||
- Session + CSRF Authentication für alle Admin-Endpoints
|
- Bearer Token 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
|
||||||
|
|
||||||
|
|
@ -71,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 (Session + CSRF Authentication)
|
- `/api/admin/*` - Admin-Endpoints (Bearer Token Authentication)
|
||||||
- `/api/system/migration/*` - Datenbank-Migrationen
|
- `/api/system/migration/*` - Datenbank-Migrationen
|
||||||
|
|
||||||
**⚠️ Express Route-Reihenfolge beachten:**
|
**⚠️ Express Route-Reihenfolge beachten:**
|
||||||
|
|
@ -90,33 +91,17 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
|
||||||
|
|
||||||
**Zwei Auth-Systeme parallel:**
|
**Zwei Auth-Systeme parallel:**
|
||||||
|
|
||||||
1. **Admin API (Session + CSRF)**:
|
1. **Admin API (Bearer Token)**:
|
||||||
```bash
|
```bash
|
||||||
# .env konfigurieren:
|
# .env konfigurieren:
|
||||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
ADMIN_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
# Initialen Admin anlegen (falls benötigt)
|
# API-Aufrufe:
|
||||||
curl -c cookies.txt http://localhost:5001/auth/setup/status
|
curl -H "Authorization: Bearer your-secure-key-here" \
|
||||||
curl -X POST -H "Content-Type: application/json" \
|
http://localhost:5001/api/admin/groups
|
||||||
-c cookies.txt -b cookies.txt \
|
|
||||||
-d '{"username":"admin","password":"SuperSicher123"}' \
|
|
||||||
http://localhost:5001/auth/setup/initial-admin
|
|
||||||
|
|
||||||
# Login + CSRF Token holen
|
|
||||||
curl -X POST -H "Content-Type: application/json" \
|
|
||||||
-c cookies.txt -b cookies.txt \
|
|
||||||
-d '{"username":"admin","password":"SuperSicher123"}' \
|
|
||||||
http://localhost:5001/auth/login
|
|
||||||
CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')
|
|
||||||
|
|
||||||
# Authentifizierter Admin-Request
|
|
||||||
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
|
||||||
http://localhost:5001/api/admin/groups
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Management Portal (UUID Token)**:
|
2. **Management Portal (UUID Token)**:
|
||||||
User, die Bilder hochladen, erhalten automatisch einen UUID-Token für das Self-Service Management Portal.
|
|
||||||
Über diesen Token / Link können sie ihre hochgeladenen Gruppen verwalten:
|
|
||||||
```bash
|
```bash
|
||||||
# Automatisch beim Upload generiert
|
# Automatisch beim Upload generiert
|
||||||
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
|
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
@ -124,18 +109,13 @@ Router mit spezifischen Routes **vor** generischen Routes mounten!
|
||||||
|
|
||||||
📖 **Vollständige Doku**: `AUTHENTICATION.md`
|
📖 **Vollständige Doku**: `AUTHENTICATION.md`
|
||||||
|
|
||||||
#### Admin-Hinweise: Logout & neue Nutzer
|
|
||||||
|
|
||||||
- **Logout:** Der Moderationsbereich enthält jetzt einen Logout-Button (Icon in der Kopfzeile). Klick → `POST /auth/logout` → Session beendet, Login erscheint erneut. Für Skripte kannst du weiterhin `curl -b cookies.txt -X POST http://localhost:5001/auth/logout` verwenden.
|
|
||||||
- **Weiterer Admin:** Verwende das neue API-basierte Skript `./scripts/create_admin_user.sh --server http://localhost:5001 --username zweiteradmin --password 'SuperPasswort123!' [--admin-user bestehend --admin-password ... --role ... --require-password-change]`. Das Skript erledigt Login, CSRF, Duplikats-Check und legt zusätzliche Admins über `/api/admin/users` an (Fallback: `backend/src/scripts/createAdminUser.js`).
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
@ -177,8 +157,7 @@ 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/session.js` - Express-Session + SQLite Store
|
- `middlewares/auth.js` - Admin Authentication (Bearer Token)
|
||||||
- `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
|
||||||
|
|
||||||
|
|
@ -302,35 +281,6 @@ describe('Example API', () => {
|
||||||
# 5. Log prüfen: GET http://localhost:5001/api/admin/deletion-log
|
# 5. Log prüfen: GET http://localhost:5001/api/admin/deletion-log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telegram-Benachrichtigungen testen
|
|
||||||
|
|
||||||
**Voraussetzung:** Bot-Setup abgeschlossen (siehe `scripts/README.telegram.md`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. ENV-Variablen in docker/dev/backend/config/.env konfigurieren:
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
TELEGRAM_BOT_TOKEN=<dein-bot-token>
|
|
||||||
TELEGRAM_CHAT_ID=<deine-chat-id>
|
|
||||||
|
|
||||||
# 2. Backend neu starten (lädt neue ENV-Variablen):
|
|
||||||
docker compose -f docker/dev/docker-compose.yml restart backend-dev
|
|
||||||
|
|
||||||
# 3. Test-Nachricht wird automatisch beim Server-Start gesendet
|
|
||||||
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
|
|
||||||
|
|
||||||
# 4. Upload-Benachrichtigung testen (Phase 3+):
|
|
||||||
curl -X POST http://localhost:5001/api/upload-batch \
|
|
||||||
-F "images=@test.jpg" \
|
|
||||||
-F "year=2024" \
|
|
||||||
-F "title=Test Upload" \
|
|
||||||
-F "name=Test User" \
|
|
||||||
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
|
|
||||||
# → Prüfe Telegram-Gruppe auf Benachrichtigung
|
|
||||||
|
|
||||||
# 5. Service manuell deaktivieren:
|
|
||||||
TELEGRAM_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### API-Tests
|
### API-Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -459,237 +409,6 @@ git commit -m "feat: Add new feature"
|
||||||
git push origin feature/my-feature
|
git push origin feature/my-feature
|
||||||
```
|
```
|
||||||
|
|
||||||
### Git Hook (optional Absicherung)
|
|
||||||
|
|
||||||
Standard-Deployments sollten `ADMIN_SESSION_COOKIE_SECURE=true` behalten, damit das Session-Cookie nur über HTTPS übertragen wird.
|
|
||||||
Das bereitgestellte Pre-Commit-Hook stellt sicher, dass der Wert in `docker/prod/docker-compose.yml` automatisch auf `true` zurückgesetzt wird, falls er versehentlich verändert wurde (z. B. nach einem Test auf HTTP-only Hardware):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ln -s ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
|
||||||
```
|
|
||||||
|
|
||||||
Nach der Installation aktualisiert der Hook die Datei bei Bedarf und staged sie direkt.
|
|
||||||
Für lokale HTTP-Lab-Deployments nutze eine separate (gitignorierte) `docker-compose.override.yml`, um `ADMIN_SESSION_COOKIE_SECURE=false` nur zur Laufzeit zu setzen. Entfernen kannst du den Hook jederzeit über `rm .git/hooks/pre-commit`.
|
|
||||||
|
|
||||||
## Host-Separation Testing (Public/Internal Hosts)
|
|
||||||
|
|
||||||
Die Applikation unterstützt eine Public/Internal Host-Separation für die Produktion. Lokal kann dies mit /etc/hosts-Einträgen getestet werden.
|
|
||||||
|
|
||||||
### Schnellstart: Lokales Testing mit /etc/hosts
|
|
||||||
|
|
||||||
**1. Hosts-Datei bearbeiten:**
|
|
||||||
|
|
||||||
**Linux / Mac:**
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (als Administrator):**
|
|
||||||
1. Notepad öffnen (als Administrator)
|
|
||||||
2. Datei öffnen: `C:\Windows\System32\drivers\etc\hosts`
|
|
||||||
3. Dateifilter auf "Alle Dateien" ändern
|
|
||||||
|
|
||||||
Füge hinzu:
|
|
||||||
```
|
|
||||||
127.0.0.1 public.test.local
|
|
||||||
127.0.0.1 internal.test.local
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Docker .env anpassen:**
|
|
||||||
|
|
||||||
Bearbeite `docker/dev/frontend/config/.env`:
|
|
||||||
```bash
|
|
||||||
API_URL=http://localhost:5001
|
|
||||||
CLIENT_URL=http://localhost:3000
|
|
||||||
APP_VERSION=1.1.0
|
|
||||||
PUBLIC_HOST=public.test.local
|
|
||||||
INTERNAL_HOST=internal.test.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Bearbeite `docker/dev/docker-compose.yml`:
|
|
||||||
```yaml
|
|
||||||
backend-dev:
|
|
||||||
environment:
|
|
||||||
- PUBLIC_HOST=public.test.local
|
|
||||||
- INTERNAL_HOST=internal.test.local
|
|
||||||
- ENABLE_HOST_RESTRICTION=true
|
|
||||||
- TRUST_PROXY_HOPS=0
|
|
||||||
|
|
||||||
frontend-dev:
|
|
||||||
environment:
|
|
||||||
- HOST=0.0.0.0
|
|
||||||
- DANGEROUSLY_DISABLE_HOST_CHECK=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Container starten:**
|
|
||||||
```bash
|
|
||||||
./dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Im Browser testen:**
|
|
||||||
|
|
||||||
**Public Host** (`http://public.test.local:3000`):
|
|
||||||
- ✅ Upload-Seite funktioniert
|
|
||||||
- ✅ UUID Management funktioniert (`/manage/:token`)
|
|
||||||
- ✅ Social Media Badges angezeigt
|
|
||||||
- ❌ Kein Admin/Groups/Slideshow-Menü
|
|
||||||
- ❌ `/moderation` → 404
|
|
||||||
|
|
||||||
**Internal Host** (`http://internal.test.local:3000`):
|
|
||||||
- ✅ Alle Features verfügbar
|
|
||||||
- ✅ Admin-Bereich, Groups, Slideshow erreichbar
|
|
||||||
- ✅ Vollständiger API-Zugriff
|
|
||||||
|
|
||||||
### API-Tests mit curl
|
|
||||||
|
|
||||||
**Public Host - Blockierte Routen (403):**
|
|
||||||
```bash
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/admin/deletion-log
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/groups
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/auth/login
|
|
||||||
```
|
|
||||||
|
|
||||||
**Public Host - Erlaubte Routen:**
|
|
||||||
```bash
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/upload
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/manage/YOUR-UUID
|
|
||||||
curl -H "Host: public.test.local" http://localhost:5001/api/social-media/platforms
|
|
||||||
```
|
|
||||||
|
|
||||||
**Internal Host - Alle Routen:**
|
|
||||||
```bash
|
|
||||||
curl -H "Host: internal.test.local" http://localhost:5001/api/groups
|
|
||||||
curl -H "Host: internal.test.local" http://localhost:5001/api/admin/deletion-log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Code-Splitting testen
|
|
||||||
|
|
||||||
**Public Host:**
|
|
||||||
1. Browser DevTools → Network → JS Filter
|
|
||||||
2. Öffne `http://public.test.local:3000`
|
|
||||||
3. **Erwartung:** Slideshow/Admin/Groups-Bundles werden **nicht** geladen
|
|
||||||
4. Navigiere zu `/admin` → Redirect zu 404
|
|
||||||
|
|
||||||
**Internal Host:**
|
|
||||||
1. Öffne `http://internal.test.local:3000`
|
|
||||||
2. Navigiere zu `/slideshow`
|
|
||||||
3. **Erwartung:** Lazy-Bundle wird erst jetzt geladen (Code Splitting)
|
|
||||||
|
|
||||||
### Rate Limiting testen
|
|
||||||
|
|
||||||
Public Host: 20 Uploads/Stunde
|
|
||||||
|
|
||||||
```bash
|
|
||||||
for i in {1..25}; do
|
|
||||||
echo "Upload $i"
|
|
||||||
curl -X POST -H "Host: public.test.local" \
|
|
||||||
http://localhost:5001/api/upload \
|
|
||||||
-F "file=@test.jpg" -F "group=Test"
|
|
||||||
done
|
|
||||||
# Ab Upload 21: HTTP 429 (Too Many Requests)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
**"Invalid Host header"**
|
|
||||||
- Lösung: `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development` (Frontend)
|
|
||||||
|
|
||||||
**"Alle Routen geben 403"**
|
|
||||||
- Prüfe `ENABLE_HOST_RESTRICTION=true`
|
|
||||||
- Prüfe `PUBLIC_HOST` / `INTERNAL_HOST` ENV-Variablen
|
|
||||||
- Container neu starten
|
|
||||||
|
|
||||||
**"public.test.local nicht erreichbar"**
|
|
||||||
- Prüfe `/etc/hosts`: `cat /etc/hosts | grep test.local`
|
|
||||||
- DNS-Cache leeren:
|
|
||||||
- **Linux:** `sudo systemd-resolve --flush-caches`
|
|
||||||
- **Mac:** `sudo dscacheutil -flushcache`
|
|
||||||
- **Windows:** `ipconfig /flushdns`
|
|
||||||
|
|
||||||
**Feature deaktivieren (Standard Dev):**
|
|
||||||
```yaml
|
|
||||||
backend-dev:
|
|
||||||
environment:
|
|
||||||
- ENABLE_HOST_RESTRICTION=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Setup
|
|
||||||
|
|
||||||
Für Production mit echten Subdomains siehe:
|
|
||||||
- `FeatureRequests/FEATURE_PLAN-FrontendPublic.md` (Sektion 12: Testing Strategy)
|
|
||||||
- nginx-proxy-manager Konfiguration erforderlich
|
|
||||||
- Hosts: `deinprojekt.hobbyhimmel.de` (public), `deinprojekt.lan.hobbyhimmel.de` (internal)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Release Management
|
|
||||||
|
|
||||||
### Automated Release (EMPFOHLEN)
|
|
||||||
|
|
||||||
**Ein Befehl macht alles:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run release # Patch: 1.2.0 → 1.2.1
|
|
||||||
npm run release:minor # Minor: 1.2.0 → 1.3.0
|
|
||||||
npm run release:major # Major: 1.2.0 → 2.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Was passiert automatisch:**
|
|
||||||
1. ✅ Version in allen package.json erhöht
|
|
||||||
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
|
|
||||||
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
|
|
||||||
4. ✅ Git Commit erstellt
|
|
||||||
5. ✅ Git Tag erstellt
|
|
||||||
6. ✅ Preview anzeigen + Bestätigung
|
|
||||||
|
|
||||||
Dann nur noch:
|
|
||||||
```bash
|
|
||||||
git push && git push --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
### Beispiel-Workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Features entwickeln mit Conventional Commits:
|
|
||||||
git commit -m "feat: Add user login"
|
|
||||||
git commit -m "fix: Fix button alignment"
|
|
||||||
git commit -m "refactor: Extract ConsentFilter component"
|
|
||||||
|
|
||||||
# Release erstellen:
|
|
||||||
npm run release:minor
|
|
||||||
|
|
||||||
# Preview wird angezeigt, dann [Y] drücken
|
|
||||||
# Push:
|
|
||||||
git push && git push --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
### CHANGELOG wird automatisch generiert!
|
|
||||||
|
|
||||||
Das Release-Script (`scripts/release.sh`) gruppiert deine Commits nach Typ:
|
|
||||||
- `feat:` → ✨ Features
|
|
||||||
- `fix:` → 🐛 Fixes
|
|
||||||
- `refactor:` → ♻️ Refactoring
|
|
||||||
- `chore:` → 🔧 Chores
|
|
||||||
- `docs:` → 📚 Documentation
|
|
||||||
|
|
||||||
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
|
|
||||||
|
|
||||||
### Manuelle Scripts (falls nötig)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Version nur synchronisieren (ohne Bump):
|
|
||||||
./scripts/sync-version.sh
|
|
||||||
|
|
||||||
# Version manuell bumpen:
|
|
||||||
./scripts/bump-version.sh patch # oder minor/major
|
|
||||||
```
|
|
||||||
|
|
||||||
**Version-Synchronisation:**
|
|
||||||
- Single Source of Truth: `frontend/package.json`
|
|
||||||
- Wird synchronisiert zu: `backend/package.json`, `Footer.js`, `generate-openapi.js`, Docker Images
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nützliche Befehle
|
## Nützliche Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
155
README.md
155
README.md
|
|
@ -5,7 +5,6 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Multi-Image Upload**: Upload multiple images at once with batch processing
|
**Multi-Image Upload**: Upload multiple images at once with batch processing
|
||||||
**Telegram Notifications**: 🆕 Real-time notifications for uploads, consent changes, deletions, and daily warnings
|
|
||||||
**Social Media Consent Management**: 🆕 GDPR-compliant consent system for workshop display and social media publishing
|
**Social Media Consent Management**: 🆕 GDPR-compliant consent system for workshop display and social media publishing
|
||||||
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
|
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
|
||||||
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
|
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
|
||||||
|
|
@ -21,7 +20,83 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
|
||||||
## What's New
|
## What's New
|
||||||
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
|
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
|
||||||
|
|
||||||
See the [CHANGELOG](CHANGELOG.md) for a detailed list of improvements and new features.
|
### 🆕 Latest Features (November 2025)
|
||||||
|
|
||||||
|
- **🧪 Comprehensive Test Suite** (Nov 16):
|
||||||
|
- 45 automated tests covering all API endpoints (100% passing)
|
||||||
|
- Jest + Supertest integration testing framework
|
||||||
|
- Unit tests for authentication middleware
|
||||||
|
- API tests for admin, consent, migration, and upload endpoints
|
||||||
|
- In-memory SQLite database for isolated testing
|
||||||
|
- Coverage: 26% statements, 15% branches (realistic starting point)
|
||||||
|
- Test execution time: ~10 seconds for full suite
|
||||||
|
- CI/CD ready with proper teardown and cleanup
|
||||||
|
|
||||||
|
- **🔒 Admin API Authentication** (Nov 16):
|
||||||
|
- Bearer token authentication for all admin endpoints
|
||||||
|
- Secure ADMIN_API_KEY environment variable configuration
|
||||||
|
- Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
|
||||||
|
- 403 Forbidden responses for missing/invalid tokens
|
||||||
|
- Complete authentication documentation in `AUTHENTICATION.md`
|
||||||
|
- Ready for production deployment with token rotation support
|
||||||
|
|
||||||
|
- **📋 API Route Documentation** (Nov 16):
|
||||||
|
- Single Source of Truth: `backend/src/routes/routeMappings.js`
|
||||||
|
- Comprehensive route overview in `backend/src/routes/README.md`
|
||||||
|
- Critical Express routing order documented (specific before generic)
|
||||||
|
- Frontend-ready route reference with authentication requirements
|
||||||
|
- OpenAPI specification auto-generation integrated
|
||||||
|
|
||||||
|
- **🔐 Social Media Consent Management** (Phase 1 Complete - Nov 9-10):
|
||||||
|
- GDPR-compliant consent system for image usage
|
||||||
|
- Mandatory workshop display consent (no upload without approval)
|
||||||
|
- Optional per-platform consents (Facebook, Instagram, TikTok)
|
||||||
|
- Consent badges and filtering in moderation panel
|
||||||
|
- CSV/JSON export for legal documentation
|
||||||
|
- Group ID tracking for consent withdrawal requests
|
||||||
|
- **🔑 Self-Service Management Portal** (Phase 2 Complete - Nov 11-15):
|
||||||
|
- Secure UUID-based management tokens for user self-service
|
||||||
|
- Frontend portal at `/manage/:token` for consent management
|
||||||
|
- Revoke/restore consents for workshop and social media
|
||||||
|
- Edit metadata (title, description) after upload
|
||||||
|
- Add/delete images after upload (with moderation re-approval)
|
||||||
|
- Complete group deletion with audit trail
|
||||||
|
- IP-based rate limiting (10 requests/hour)
|
||||||
|
- Brute-force protection (20 failed attempts → 24h ban)
|
||||||
|
- Management audit log for security tracking
|
||||||
|
- **🎨 Modular UI Architecture** (Nov 15):
|
||||||
|
- Reusable components: ConsentManager, GroupMetadataEditor, ImageDescriptionManager
|
||||||
|
- Multi-mode support: upload/edit/moderate modes for maximum reusability
|
||||||
|
- Code reduction: 62% in ModerationGroupImagesPage (281→107 lines)
|
||||||
|
- Consistent design: HTML buttons, Paper boxes, Material-UI Alerts
|
||||||
|
- Individual save/discard per component section
|
||||||
|
- Zero code duplication between pages
|
||||||
|
- **<EFBFBD> Slideshow Optimization**: Intelligent image preloading eliminates loading delays and duplicate images
|
||||||
|
- **📅 Chronological Display**: Slideshows now play in chronological order (year → upload date)
|
||||||
|
- **Automatic Cleanup**: Unapproved groups are automatically deleted after 7 days
|
||||||
|
- **Deletion Log**: Complete audit trail with statistics (groups, images, storage freed)
|
||||||
|
- **Countdown Display**: Visual indicator showing days until automatic deletion
|
||||||
|
- **Approval Feedback**: SweetAlert2 notifications for moderation actions
|
||||||
|
- **Manual Cleanup Trigger**: Admin API endpoints for testing and manual cleanup
|
||||||
|
- **Image Descriptions**: Add optional descriptions to individual images (max 200 characters)
|
||||||
|
- **Edit Mode**: Edit descriptions for uploaded images in upload preview and moderation interface
|
||||||
|
- **Slideshow Display**: Image descriptions shown as overlays during slideshow presentation
|
||||||
|
- **Public Display**: Descriptions visible in public group views and galleries
|
||||||
|
|
||||||
|
### Previous Features (October 2025)
|
||||||
|
- **Drag-and-Drop Image Reordering**: Admins can now reorder images using intuitive drag-and-drop
|
||||||
|
- **Touch-Friendly Interface**: Mobile-optimized controls with always-visible drag handles
|
||||||
|
- **Slideshow Integration**: Custom image order automatically applies to slideshow mode
|
||||||
|
- **Optimistic UI Updates**: Immediate visual feedback with error recovery
|
||||||
|
- **Comprehensive Admin Panel**: Dedicated moderation interface for content curation
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- Multi-image batch upload with progress tracking
|
||||||
|
- Automatic slideshow presentation mode
|
||||||
|
- Image grouping with descriptions and metadata
|
||||||
|
- Random slideshow rotation with custom ordering support
|
||||||
|
- Keyboard navigation support (Slideshow: Space/Arrow keys, Escape to exit)
|
||||||
|
- Mobile-responsive design with touch-first interactions
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -131,12 +206,11 @@ The application automatically generates optimized preview thumbnails for all upl
|
||||||
|
|
||||||
### Moderation Interface (Protected)
|
### Moderation Interface (Protected)
|
||||||
|
|
||||||
- **Access**: `http://localhost/moderation` (requires admin session)
|
- **Access**: `http://localhost/moderation` (requires authentication)
|
||||||
- **Authentication Flow**:
|
- **Authentication Methods**:
|
||||||
- Built-in login form establishes a server session stored in HttpOnly cookies
|
- **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
|
||||||
- First-time setup wizard creates the initial admin user once `ADMIN_SESSION_SECRET` is configured
|
- **API Direct Access**: Bearer Token via `Authorization: Bearer <ADMIN_API_KEY>` header
|
||||||
- CSRF token must be included (header `X-CSRF-Token`) for any mutating admin API call
|
- See `AUTHENTICATION.md` for detailed setup instructions
|
||||||
- `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
|
||||||
|
|
@ -176,31 +250,31 @@ The application automatically generates optimized preview thumbnails for all upl
|
||||||
|
|
||||||
## Docker Structure
|
## Docker Structure
|
||||||
|
|
||||||
The application uses separate Docker configurations for development and production with **simplified environment variable management**:
|
The application uses separate Docker configurations for development and production:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker/
|
docker/
|
||||||
├── .env.backend.example # Backend environment variables documentation
|
├── .env.backend.example # Backend environment variables documentation
|
||||||
├── .env.frontend.example # Frontend environment variables documentation
|
├── .env.frontend.example # Frontend environment variables documentation
|
||||||
├── dev/ # Development environment
|
├── dev/ # Development environment
|
||||||
│ ├── .env # 🆕 Central dev secrets (gitignored)
|
│ ├── docker-compose.yml # Development services configuration
|
||||||
│ ├── .env.example # Dev environment template
|
|
||||||
│ ├── docker-compose.yml # All ENV vars defined here
|
|
||||||
│ ├── backend/
|
│ ├── backend/
|
||||||
|
│ │ ├── config/.env # Development backend configuration
|
||||||
│ │ └── Dockerfile # Development backend container
|
│ │ └── Dockerfile # Development backend container
|
||||||
│ └── frontend/
|
│ └── frontend/
|
||||||
│ ├── config/env.sh # Generates window._env_ from ENV
|
│ ├── config/.env # Development frontend configuration
|
||||||
|
│ ├── config/env.sh # Runtime configuration script
|
||||||
│ ├── Dockerfile # Development frontend container
|
│ ├── Dockerfile # Development frontend container
|
||||||
│ ├── nginx.conf # Development nginx configuration
|
│ ├── nginx.conf # Development nginx configuration
|
||||||
│ └── start.sh # Development startup script
|
│ └── start.sh # Development startup script
|
||||||
└── prod/ # Production environment
|
└── prod/ # Production environment
|
||||||
├── .env # 🆕 Central prod secrets (gitignored)
|
├── docker-compose.yml # Production services configuration
|
||||||
├── .env.example # Production environment template
|
|
||||||
├── docker-compose.yml # All ENV vars defined here
|
|
||||||
├── backend/
|
├── backend/
|
||||||
|
│ ├── config/.env # Production backend configuration
|
||||||
│ └── Dockerfile # Production backend container
|
│ └── Dockerfile # Production backend container
|
||||||
└── frontend/
|
└── frontend/
|
||||||
├── config/env.sh # Generates window._env_ from ENV
|
├── config/.env # Production frontend configuration
|
||||||
|
├── config/env.sh # Runtime configuration script
|
||||||
├── config/htpasswd # HTTP Basic Auth credentials
|
├── config/htpasswd # HTTP Basic Auth credentials
|
||||||
├── Dockerfile # Production frontend container
|
├── Dockerfile # Production frontend container
|
||||||
└── nginx.conf # Production nginx configuration
|
└── nginx.conf # Production nginx configuration
|
||||||
|
|
@ -208,20 +282,6 @@ docker/
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
**🆕 Simplified ENV Structure (Nov 2025):**
|
|
||||||
- **2 central `.env` files** (down from 16 files!)
|
|
||||||
- `docker/dev/.env` - All development secrets
|
|
||||||
- `docker/prod/.env` - All production secrets
|
|
||||||
- **docker-compose.yml** - All environment variables defined in `environment:` sections
|
|
||||||
- **No .env files in Docker images** - All configuration via docker-compose
|
|
||||||
- **Frontend env.sh** - Generates `window._env_` JavaScript object from ENV variables at runtime
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
1. Docker Compose automatically reads `.env` from the same directory
|
|
||||||
2. Variables are injected into containers via `environment:` sections using `${VAR}` placeholders
|
|
||||||
3. Frontend `env.sh` script reads ENV variables and generates JavaScript config at container startup
|
|
||||||
4. Secrets stay in gitignored `.env` files, never in code or images
|
|
||||||
|
|
||||||
- **Development**: Uses `docker/dev/` configuration with live reloading
|
- **Development**: Uses `docker/dev/` configuration with live reloading
|
||||||
- **Production**: Uses `docker/prod/` configuration with optimized builds
|
- **Production**: Uses `docker/prod/` configuration with optimized builds
|
||||||
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
|
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
|
||||||
|
|
@ -519,41 +579,12 @@ The application includes comprehensive testing tools for the automatic cleanup f
|
||||||
For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
|
For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
**Simplified ENV Management (Nov 2025):**
|
|
||||||
All environment variables are now managed through **2 central `.env` files** and `docker-compose.yml`:
|
|
||||||
|
|
||||||
**Core Variables:**
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `API_URL` | `http://localhost:5001` | Backend API endpoint (frontend → backend) |
|
| `API_URL` | `http://localhost:5000` | Backend API endpoint |
|
||||||
| `PUBLIC_HOST` | `public.test.local` | Public upload subdomain (no admin access) |
|
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
|
||||||
| `INTERNAL_HOST` | `internal.test.local` | Internal admin subdomain (full access) |
|
|
||||||
| `ADMIN_SESSION_SECRET` | - | Secret for admin session cookies (required) |
|
|
||||||
|
|
||||||
**Telegram Notifications (Optional):**
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `TELEGRAM_ENABLED` | `false` | Enable/disable Telegram notifications |
|
|
||||||
| `TELEGRAM_BOT_TOKEN` | - | Telegram Bot API token (from @BotFather) |
|
|
||||||
| `TELEGRAM_CHAT_ID` | - | Telegram chat/group ID for notifications |
|
|
||||||
| `TELEGRAM_SEND_TEST_ON_START` | `false` | Send test message on service startup (dev only) |
|
|
||||||
|
|
||||||
**Configuration Files:**
|
|
||||||
- `docker/dev/.env` - Development secrets (gitignored)
|
|
||||||
- `docker/prod/.env` - Production secrets (gitignored)
|
|
||||||
- `docker/dev/.env.example` - Development template (committed)
|
|
||||||
- `docker/prod/.env.example` - Production template (committed)
|
|
||||||
|
|
||||||
**How to configure:**
|
|
||||||
1. Copy `.env.example` to `.env` in the respective environment folder
|
|
||||||
2. Edit `.env` and set your secrets (ADMIN_SESSION_SECRET, Telegram tokens, etc.)
|
|
||||||
3. Docker Compose automatically reads `.env` and injects variables into containers
|
|
||||||
4. Never commit `.env` files (already in `.gitignore`)
|
|
||||||
|
|
||||||
**Telegram Setup:** See `scripts/README.telegram.md` for complete configuration guide.
|
|
||||||
|
|
||||||
### Volume Configuration
|
### Volume Configuration
|
||||||
- **Upload Limits**: 100MB maximum file size for batch uploads
|
- **Upload Limits**: 100MB maximum file size for batch uploads
|
||||||
|
|
|
||||||
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
|
||||||
[x] 🎨 Drag & Drop Reihenfolge ändern
|
- 🎨 Drag & Drop Reihenfolge ändern
|
||||||
[x] 📊 Upload-Progress mit Details
|
- 📊 Upload-Progress mit Details
|
||||||
[x] 🖼️ Thumbnail-Navigation in Slideshow
|
- 🖼️ Thumbnail-Navigation in Slideshow
|
||||||
|
- 🔄 Batch-Operations (alle entfernen, etc.)
|
||||||
|
|
||||||
### Future Features
|
### Future Features
|
||||||
- 👤 User-Management
|
- 👤 User-Management
|
||||||
|
- 🏷️ Tagging-System
|
||||||
|
- 📤 Export-Funktionen
|
||||||
|
- 🎵 Audio-Integration
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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,7 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -26,9 +25,5 @@ module.exports = {
|
||||||
// Run tests serially to avoid DB conflicts
|
// Run tests serially to avoid DB conflicts
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
// Force exit after tests complete
|
// Force exit after tests complete
|
||||||
forceExit: true,
|
forceExit: true
|
||||||
// Transform ESM modules in node_modules
|
|
||||||
transformIgnorePatterns: [
|
|
||||||
'node_modules/(?!(uuid)/)'
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "2.0.1",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"server": "nodemon --ignore docs/openapi.json src/index.js",
|
"server": "nodemon 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,23 +15,18 @@
|
||||||
"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",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"shortid": "^2.2.16",
|
"shortid": "^2.2.16",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,18 @@ const fs = require('fs');
|
||||||
class DatabaseManager {
|
class DatabaseManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.db = null;
|
this.db = null;
|
||||||
this.dbPath = null;
|
// Use in-memory database for tests, file-based for production
|
||||||
this.schemaPath = path.join(__dirname, 'schema.sql');
|
|
||||||
}
|
|
||||||
|
|
||||||
getDatabasePath() {
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
return ':memory:';
|
this.dbPath = ':memory:';
|
||||||
|
} else {
|
||||||
|
// Place database file under data/db
|
||||||
|
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
|
||||||
}
|
}
|
||||||
return path.join(__dirname, '../data/db/image_uploader.db');
|
this.schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -51,10 +47,8 @@ class DatabaseManager {
|
||||||
// Run database migrations (automatic on startup)
|
// Run database migrations (automatic on startup)
|
||||||
await this.runMigrations();
|
await this.runMigrations();
|
||||||
|
|
||||||
const skipPreviewGeneration = ['1', 'true', 'yes'].includes(String(process.env.SKIP_PREVIEW_GENERATION || '').toLowerCase());
|
// Generate missing previews for existing images (skip in test mode)
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,31 +168,6 @@ 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);
|
||||||
|
|
@ -219,19 +188,6 @@ 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) => {
|
||||||
|
|
@ -417,27 +373,29 @@ 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) to avoid sqlite parser issues
|
// Remove comments (both line and inline) before splitting
|
||||||
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();
|
|
||||||
|
|
||||||
if (!cleanedSql) {
|
// Split by semicolon and execute each statement
|
||||||
console.warn(` ⚠️ Migration ${file} enthält keinen ausführbaren SQL-Code, übersprungen`);
|
const statements = cleanedSql
|
||||||
await this.run('COMMIT');
|
.split(';')
|
||||||
continue;
|
.map(s => s.trim())
|
||||||
|
.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 (?)',
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- Migration 009: Add source tracking to audit log
|
|
||||||
-- Adds source_host and source_type columns to management_audit_log
|
|
||||||
|
|
||||||
-- Add source_host column (stores the hostname from which request originated)
|
|
||||||
ALTER TABLE management_audit_log ADD COLUMN source_host TEXT;
|
|
||||||
|
|
||||||
-- Add source_type column (stores 'public' or 'internal')
|
|
||||||
ALTER TABLE management_audit_log ADD COLUMN source_type TEXT;
|
|
||||||
|
|
||||||
-- Create index for filtering by source_type
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_source_type ON management_audit_log(source_type);
|
|
||||||
|
|
@ -48,25 +48,3 @@ 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;
|
|
||||||
|
|
@ -16,10 +16,10 @@ const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
|
||||||
const doc = {
|
const doc = {
|
||||||
info: {
|
info: {
|
||||||
title: 'Project Image Uploader API',
|
title: 'Project Image Uploader API',
|
||||||
version: '2.0.1',
|
version: '1.0.0',
|
||||||
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
|
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
|
||||||
},
|
},
|
||||||
host: 'localhost:5001',
|
host: 'localhost:5000',
|
||||||
schemes: ['http'],
|
schemes: ['http'],
|
||||||
// Add base path hints per router (swagger-autogen doesn't natively support per-file prefixes,
|
// Add base path hints per router (swagger-autogen doesn't natively support per-file prefixes,
|
||||||
// so we'll post-process or use @swagger annotations in route files)
|
// so we'll post-process or use @swagger annotations in route files)
|
||||||
|
|
@ -71,7 +71,7 @@ async function generateWithPrefixes() {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: doc.info,
|
info: doc.info,
|
||||||
servers: [
|
servers: [
|
||||||
{ url: 'http://localhost:5001', description: 'Development server (dev compose backend)' }
|
{ url: 'http://localhost:5000', description: 'Development server' }
|
||||||
],
|
],
|
||||||
tags: Array.from(allTags).map(name => ({ name })),
|
tags: Array.from(allTags).map(name => ({ name })),
|
||||||
paths: allPaths
|
paths: allPaths
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ const auditLogMiddleware = (req, res, next) => {
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
|
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
|
||||||
const userAgent = req.get('user-agent') || 'unknown';
|
const userAgent = req.get('user-agent') || 'unknown';
|
||||||
const managementToken = req.params.token || null;
|
const managementToken = req.params.token || null;
|
||||||
const sourceHost = req.get('x-forwarded-host') || req.get('host') || 'unknown';
|
|
||||||
const sourceType = req.requestSource || 'unknown';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log-Funktion für Controllers
|
* Log-Funktion für Controllers
|
||||||
|
|
@ -35,9 +33,7 @@ const auditLogMiddleware = (req, res, next) => {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
requestData,
|
requestData
|
||||||
sourceHost,
|
|
||||||
sourceType
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to write audit log:', error);
|
console.error('Failed to write audit log:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,49 @@
|
||||||
/**
|
/**
|
||||||
* Admin Authentication Middleware
|
* Admin Authentication Middleware
|
||||||
* Validates server-side session for admin users
|
* Validates Bearer token from Authorization header against ADMIN_API_KEY env variable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const requireAdminAuth = (req, res, next) => {
|
const requireAdminAuth = (req, res, next) => {
|
||||||
const sessionUser = req.session && req.session.user;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!sessionUser || sessionUser.role !== 'admin') {
|
// Check if Authorization header exists
|
||||||
|
if (!authHeader) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Zugriff verweigert',
|
error: 'Zugriff verweigert',
|
||||||
reason: 'SESSION_REQUIRED'
|
message: 'Authorization header fehlt'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.locals.adminUser = sessionUser;
|
// Check if it's a Bearer token
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Zugriff verweigert',
|
||||||
|
message: 'Ungültiges Authorization Format. Erwartet: Bearer <token>'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = parts[1];
|
||||||
|
const adminKey = process.env.ADMIN_API_KEY;
|
||||||
|
|
||||||
|
// Check if ADMIN_API_KEY is configured
|
||||||
|
if (!adminKey) {
|
||||||
|
console.error('⚠️ ADMIN_API_KEY nicht in .env konfiguriert!');
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Server-Konfigurationsfehler',
|
||||||
|
message: 'Admin-Authentifizierung nicht konfiguriert'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
if (token !== adminKey) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Zugriff verweigert',
|
||||||
|
message: 'Ungültiger Admin-Token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token valid, proceed to route
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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,114 +0,0 @@
|
||||||
/**
|
|
||||||
* Host Gate Middleware
|
|
||||||
* Blockiert geschützte API-Routen für public Host
|
|
||||||
* Erlaubt nur Upload + Management für public Host
|
|
||||||
*
|
|
||||||
* Erkennt Host via X-Forwarded-Host (nginx-proxy-manager) oder Host Header
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PUBLIC_HOST = process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
|
|
||||||
const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
|
|
||||||
const ENABLE_HOST_RESTRICTION = process.env.ENABLE_HOST_RESTRICTION !== 'false';
|
|
||||||
|
|
||||||
// Debug: Log configuration on module load (development only)
|
|
||||||
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
|
|
||||||
console.log('🔧 hostGate config:', { PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routes die NUR für internal Host erlaubt sind
|
|
||||||
const INTERNAL_ONLY_ROUTES = [
|
|
||||||
'/api/admin',
|
|
||||||
'/api/groups',
|
|
||||||
'/api/slideshow',
|
|
||||||
'/api/migration',
|
|
||||||
'/api/moderation',
|
|
||||||
'/api/reorder',
|
|
||||||
'/api/batch-upload',
|
|
||||||
'/api/social-media',
|
|
||||||
'/api/auth/login', // Admin Login nur internal
|
|
||||||
'/api/auth/logout',
|
|
||||||
'/api/auth/session'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Routes die für public Host erlaubt sind
|
|
||||||
const PUBLIC_ALLOWED_ROUTES = [
|
|
||||||
'/api/upload',
|
|
||||||
'/api/manage',
|
|
||||||
'/api/previews',
|
|
||||||
'/api/consent',
|
|
||||||
'/api/social-media/platforms' // Nur Plattformen lesen (für Consent-Badges im UUID Management)
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware: Host-basierte Zugriffskontrolle
|
|
||||||
* @param {Object} req - Express Request
|
|
||||||
* @param {Object} res - Express Response
|
|
||||||
* @param {Function} next - Next Middleware
|
|
||||||
*/
|
|
||||||
const hostGate = (req, res, next) => {
|
|
||||||
// Feature disabled only when explicitly set to false OR in test environment without explicit enable
|
|
||||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
|
||||||
const explicitlyEnabled = process.env.ENABLE_HOST_RESTRICTION === 'true';
|
|
||||||
const explicitlyDisabled = process.env.ENABLE_HOST_RESTRICTION === 'false';
|
|
||||||
|
|
||||||
// Skip restriction if:
|
|
||||||
// - Explicitly disabled, OR
|
|
||||||
// - Test environment AND not explicitly enabled
|
|
||||||
if (explicitlyDisabled || (isTestEnv && !explicitlyEnabled)) {
|
|
||||||
req.isPublicHost = false;
|
|
||||||
req.isInternalHost = true;
|
|
||||||
req.requestSource = 'internal';
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get host from X-Forwarded-Host (nginx-proxy-manager) or Host header
|
|
||||||
const forwardedHost = req.get('x-forwarded-host');
|
|
||||||
const hostHeader = req.get('host');
|
|
||||||
const host = forwardedHost || hostHeader || '';
|
|
||||||
const hostname = host.split(':')[0]; // Remove port if present
|
|
||||||
|
|
||||||
// Determine if request is from public or internal host
|
|
||||||
req.isPublicHost = hostname === PUBLIC_HOST;
|
|
||||||
req.isInternalHost = hostname === INTERNAL_HOST || hostname === 'localhost' || hostname === '127.0.0.1';
|
|
||||||
|
|
||||||
// Log host detection for debugging
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.log(`🔍 Host Detection: ${hostname} → ${req.isPublicHost ? 'PUBLIC' : 'INTERNAL'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If public host, check if route is allowed
|
|
||||||
if (req.isPublicHost) {
|
|
||||||
const path = req.path;
|
|
||||||
|
|
||||||
// Check if explicitly allowed (z.B. /api/social-media/platforms)
|
|
||||||
const isExplicitlyAllowed = PUBLIC_ALLOWED_ROUTES.some(route =>
|
|
||||||
path === route || path.startsWith(route + '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isExplicitlyAllowed) {
|
|
||||||
// Erlaubt - kein Block
|
|
||||||
req.requestSource = 'public';
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if route is internal-only
|
|
||||||
const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route =>
|
|
||||||
path.startsWith(route)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isInternalOnly) {
|
|
||||||
console.warn(`🚫 Public host blocked access to: ${path} (Host: ${hostname})`);
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Not available on public host',
|
|
||||||
message: 'This endpoint is only available on the internal network'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add request source context for audit logging
|
|
||||||
req.requestSource = req.isPublicHost ? 'public' : 'internal';
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = hostGate;
|
|
||||||
|
|
@ -1,17 +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 hostGate = require("./hostGate");
|
|
||||||
|
|
||||||
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());
|
||||||
// Host Gate: Blockiert geschützte Routen für public Host
|
|
||||||
app.use(hostGate);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { applyMiddlewares };
|
module.exports = { applyMiddlewares };
|
||||||
|
|
@ -19,15 +19,6 @@ const RATE_LIMIT = {
|
||||||
BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden
|
BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden
|
||||||
};
|
};
|
||||||
|
|
||||||
// Public Upload Rate Limiting (strengere Limits für öffentliche Uploads)
|
|
||||||
const PUBLIC_UPLOAD_LIMIT = {
|
|
||||||
MAX_UPLOADS_PER_HOUR: parseInt(process.env.PUBLIC_UPLOAD_RATE_LIMIT || '20', 10),
|
|
||||||
WINDOW_MS: parseInt(process.env.PUBLIC_UPLOAD_RATE_WINDOW || '3600000', 10) // 1 Stunde
|
|
||||||
};
|
|
||||||
|
|
||||||
// In-Memory Storage für Public Upload Rate-Limiting
|
|
||||||
const publicUploadCounts = new Map(); // IP -> { count, resetTime }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrahiere Client-IP aus Request
|
* Extrahiere Client-IP aus Request
|
||||||
*/
|
*/
|
||||||
|
|
@ -178,63 +169,13 @@ function getStatistics() {
|
||||||
reason: info.reason,
|
reason: info.reason,
|
||||||
blockedUntil: new Date(info.blockedUntil).toISOString(),
|
blockedUntil: new Date(info.blockedUntil).toISOString(),
|
||||||
failedAttempts: info.failedAttempts
|
failedAttempts: info.failedAttempts
|
||||||
})),
|
}))
|
||||||
publicUploadActiveIPs: publicUploadCounts.size
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Public Upload Rate Limiter Middleware
|
|
||||||
* Strengere Limits für öffentliche Uploads (20 pro Stunde pro IP)
|
|
||||||
* Wird nur auf public Host angewendet
|
|
||||||
*/
|
|
||||||
function publicUploadLimiter(req, res, next) {
|
|
||||||
// Skip wenn nicht public Host oder Feature disabled
|
|
||||||
if (!req.isPublicHost || process.env.NODE_ENV === 'test') {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientIP = getClientIP(req);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Hole oder erstelle Upload-Counter für IP
|
|
||||||
let uploadInfo = publicUploadCounts.get(clientIP);
|
|
||||||
|
|
||||||
if (!uploadInfo || now > uploadInfo.resetTime) {
|
|
||||||
// Neues Zeitfenster
|
|
||||||
uploadInfo = {
|
|
||||||
count: 0,
|
|
||||||
resetTime: now + PUBLIC_UPLOAD_LIMIT.WINDOW_MS
|
|
||||||
};
|
|
||||||
publicUploadCounts.set(clientIP, uploadInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfe Upload-Limit
|
|
||||||
if (uploadInfo.count >= PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR) {
|
|
||||||
const resetIn = Math.ceil((uploadInfo.resetTime - now) / 1000 / 60);
|
|
||||||
console.warn(`🚫 Public upload limit exceeded for IP ${clientIP} (${uploadInfo.count}/${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR})`);
|
|
||||||
|
|
||||||
return res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Upload limit exceeded',
|
|
||||||
message: `You have reached the maximum of ${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR} uploads per hour. Please try again in ${resetIn} minutes.`,
|
|
||||||
limit: PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR,
|
|
||||||
resetIn: resetIn
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erhöhe Upload-Counter
|
|
||||||
uploadInfo.count++;
|
|
||||||
publicUploadCounts.set(clientIP, uploadInfo);
|
|
||||||
|
|
||||||
// Request durchlassen
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rateLimitMiddleware,
|
rateLimitMiddleware,
|
||||||
recordFailedTokenValidation,
|
recordFailedTokenValidation,
|
||||||
cleanupExpiredEntries,
|
cleanupExpiredEntries,
|
||||||
getStatistics,
|
getStatistics
|
||||||
publicUploadLimiter
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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';
|
|
||||||
const ADMIN_SESSION_COOKIE_SECURE = process.env.ADMIN_SESSION_COOKIE_SECURE;
|
|
||||||
|
|
||||||
const parseBooleanEnv = (value) => {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (value.toLowerCase().trim()) {
|
|
||||||
case 'true':
|
|
||||||
case '1':
|
|
||||||
case 'yes':
|
|
||||||
case 'on':
|
|
||||||
return true;
|
|
||||||
case 'false':
|
|
||||||
case '0':
|
|
||||||
case 'no':
|
|
||||||
case 'off':
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const secureOverride = parseBooleanEnv(ADMIN_SESSION_COOKIE_SECURE);
|
|
||||||
const cookieSecure = secureOverride ?? IS_PRODUCTION;
|
|
||||||
|
|
||||||
if (IS_PRODUCTION && secureOverride === false) {
|
|
||||||
console.warn('[Session] ADMIN_SESSION_COOKIE_SECURE=false detected – secure cookies disabled in production. Only do this on trusted HTTP deployments.');
|
|
||||||
}
|
|
||||||
|
|
||||||
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: cookieSecure,
|
|
||||||
sameSite: 'strict',
|
|
||||||
maxAge: 8 * 60 * 60 * 1000 // 8 hours
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = sessionMiddleware;
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -20,8 +20,6 @@ class ManagementAuditLogRepository {
|
||||||
* @param {string} logData.ipAddress - IP-Adresse
|
* @param {string} logData.ipAddress - IP-Adresse
|
||||||
* @param {string} logData.userAgent - User-Agent
|
* @param {string} logData.userAgent - User-Agent
|
||||||
* @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert)
|
* @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert)
|
||||||
* @param {string} logData.sourceHost - Source Host (public/internal)
|
|
||||||
* @param {string} logData.sourceType - Source Type (public/internal)
|
|
||||||
* @returns {Promise<number>} ID des Log-Eintrags
|
* @returns {Promise<number>} ID des Log-Eintrags
|
||||||
*/
|
*/
|
||||||
async logAction(logData) {
|
async logAction(logData) {
|
||||||
|
|
@ -36,50 +34,22 @@ class ManagementAuditLogRepository {
|
||||||
managementToken: undefined // Token nie loggen
|
managementToken: undefined // Token nie loggen
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
// Prüfe ob Spalten source_host und source_type existieren
|
const query = `
|
||||||
const tableInfo = await dbManager.all(`PRAGMA table_info(management_audit_log)`);
|
INSERT INTO management_audit_log
|
||||||
const hasSourceColumns = tableInfo.some(col => col.name === 'source_host');
|
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
let query, params;
|
const result = await dbManager.run(query, [
|
||||||
|
logData.groupId || null,
|
||||||
if (hasSourceColumns) {
|
maskedToken,
|
||||||
query = `
|
logData.action,
|
||||||
INSERT INTO management_audit_log
|
logData.success ? 1 : 0,
|
||||||
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data, source_host, source_type)
|
logData.errorMessage || null,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
logData.ipAddress || null,
|
||||||
`;
|
logData.userAgent || null,
|
||||||
params = [
|
sanitizedData ? JSON.stringify(sanitizedData) : null
|
||||||
logData.groupId || null,
|
]);
|
||||||
maskedToken,
|
|
||||||
logData.action,
|
|
||||||
logData.success ? 1 : 0,
|
|
||||||
logData.errorMessage || null,
|
|
||||||
logData.ipAddress || null,
|
|
||||||
logData.userAgent || null,
|
|
||||||
sanitizedData ? JSON.stringify(sanitizedData) : null,
|
|
||||||
logData.sourceHost || null,
|
|
||||||
logData.sourceType || null
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// Fallback für alte DB-Schemas ohne source_host/source_type
|
|
||||||
query = `
|
|
||||||
INSERT INTO management_audit_log
|
|
||||||
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
params = [
|
|
||||||
logData.groupId || null,
|
|
||||||
maskedToken,
|
|
||||||
logData.action,
|
|
||||||
logData.success ? 1 : 0,
|
|
||||||
logData.errorMessage || null,
|
|
||||||
logData.ipAddress || null,
|
|
||||||
logData.userAgent || null,
|
|
||||||
sanitizedData ? JSON.stringify(sanitizedData) : null
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await dbManager.run(query, params);
|
|
||||||
|
|
||||||
return result.lastID;
|
return result.lastID;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ npm run generate-openapi
|
||||||
|
|
||||||
**Generiert:** `backend/docs/openapi.json`
|
**Generiert:** `backend/docs/openapi.json`
|
||||||
|
|
||||||
**Zugriff:** http://localhost:5001/api/docs/ (nur dev-mode)
|
**Zugriff:** http://localhost:5000/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:5001/api/docs/
|
http://localhost:5000/api/docs
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hinweis:** Nur im Development-Modus verfügbar!
|
**Hinweis:** Nur im Development-Modus verfügbar!
|
||||||
|
|
|
||||||
|
|
@ -4,77 +4,14 @@ 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) => {
|
||||||
/*
|
/*
|
||||||
|
|
@ -237,46 +174,6 @@ router.post('/cleanup/trigger', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/telegram/warning', async (req, res) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin - Telegram']
|
|
||||||
#swagger.summary = 'Manually trigger Telegram deletion warning'
|
|
||||||
#swagger.description = 'Sends deletion warning to Telegram for testing (normally runs daily at 09:00)'
|
|
||||||
#swagger.responses[200] = {
|
|
||||||
description: 'Warning sent successfully',
|
|
||||||
schema: {
|
|
||||||
success: true,
|
|
||||||
groupsWarned: 2,
|
|
||||||
message: 'Deletion warning sent for 2 groups'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
const schedulerService = req.app.get('schedulerService');
|
|
||||||
|
|
||||||
if (!schedulerService) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Scheduler service not available'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await schedulerService.triggerTelegramWarningNow();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
groupsWarned: result.groupCount,
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Admin API] Error triggering Telegram warning:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/cleanup/preview', async (req, res) => {
|
router.get('/cleanup/preview', async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Admin - Cleanup']
|
#swagger.tags = ['Admin - Cleanup']
|
||||||
|
|
@ -1018,120 +915,6 @@ router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/groups/:groupId/reorder', async (req, res) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin - Groups Moderation']
|
|
||||||
#swagger.summary = 'Reorder images in a group'
|
|
||||||
#swagger.description = 'Updates the display order of images within a group'
|
|
||||||
#swagger.parameters['groupId'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Group ID',
|
|
||||||
example: 'abc123def456'
|
|
||||||
}
|
|
||||||
#swagger.requestBody = {
|
|
||||||
required: true,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['imageIds'],
|
|
||||||
properties: {
|
|
||||||
imageIds: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'integer' },
|
|
||||||
example: [5, 3, 1, 2, 4],
|
|
||||||
description: 'Array of image IDs in new order'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = {
|
|
||||||
description: 'Images reordered successfully',
|
|
||||||
schema: {
|
|
||||||
success: true,
|
|
||||||
message: 'Image order updated successfully',
|
|
||||||
data: {
|
|
||||||
updatedImages: 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#swagger.responses[400] = {
|
|
||||||
description: 'Invalid imageIds parameter'
|
|
||||||
}
|
|
||||||
#swagger.responses[404] = {
|
|
||||||
description: 'Group not found'
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
const { groupId } = req.params;
|
|
||||||
const { imageIds } = req.body;
|
|
||||||
|
|
||||||
// Validate imageIds
|
|
||||||
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'imageIds array is required and cannot be empty'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that all imageIds are numbers
|
|
||||||
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
|
|
||||||
if (invalidIds.length > 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify group exists
|
|
||||||
const groupData = await GroupRepository.getGroupById(groupId);
|
|
||||||
if (!groupData) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Group not found',
|
|
||||||
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute reorder using GroupRepository
|
|
||||||
const result = await GroupRepository.updateImageOrder(groupId, imageIds);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Image order updated successfully',
|
|
||||||
data: result
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[ADMIN] Error reordering images for group ${req.params.groupId}:`, error.message);
|
|
||||||
|
|
||||||
// Handle specific errors
|
|
||||||
if (error.message.includes('not found')) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Group or images not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.message.includes('mismatch')) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to reorder images',
|
|
||||||
message: 'Fehler beim Sortieren der Bilder'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/groups/:groupId', async (req, res) => {
|
router.delete('/groups/:groupId', async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Admin - Groups Moderation']
|
#swagger.tags = ['Admin - Groups Moderation']
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Check onboarding status'
|
|
||||||
#swagger.description = 'Returns whether the initial admin setup is still pending and if a session already exists.'
|
|
||||||
*/
|
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Complete initial admin setup'
|
|
||||||
#swagger.description = 'Creates the very first admin account and immediately starts a session.'
|
|
||||||
*/
|
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Admin login'
|
|
||||||
#swagger.description = 'Starts a server-side admin session and returns a CSRF token.'
|
|
||||||
*/
|
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Terminate admin session'
|
|
||||||
#swagger.description = 'Destroys the current session and clears the sid cookie.'
|
|
||||||
*/
|
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Fetch CSRF token'
|
|
||||||
#swagger.description = 'Returns a CSRF token for the active admin session (session required).'
|
|
||||||
*/
|
|
||||||
if (!req.session.csrfToken) {
|
|
||||||
req.session.csrfToken = AdminAuthService.generateCsrfToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ csrfToken: req.session.csrfToken });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/change-password', requireAdminAuth, requireCsrf, async (req, res) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin Authentication']
|
|
||||||
#swagger.summary = 'Change admin password'
|
|
||||||
#swagger.description = 'Allows a logged-in admin to rotate their password (CSRF protected).'
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|
@ -6,10 +6,6 @@ const UploadGroup = require('../models/uploadGroup');
|
||||||
const groupRepository = require('../repositories/GroupRepository');
|
const groupRepository = require('../repositories/GroupRepository');
|
||||||
const dbManager = require('../database/DatabaseManager');
|
const dbManager = require('../database/DatabaseManager');
|
||||||
const ImagePreviewService = require('../services/ImagePreviewService');
|
const ImagePreviewService = require('../services/ImagePreviewService');
|
||||||
const TelegramNotificationService = require('../services/TelegramNotificationService');
|
|
||||||
|
|
||||||
// Singleton-Instanz des Telegram Service
|
|
||||||
const telegramService = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -88,15 +84,6 @@ const router = Router();
|
||||||
*/
|
*/
|
||||||
// Batch-Upload für mehrere Bilder
|
// Batch-Upload für mehrere Bilder
|
||||||
router.post('/upload/batch', async (req, res) => {
|
router.post('/upload/batch', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Upload']
|
|
||||||
#swagger.summary = 'Batch upload multiple images'
|
|
||||||
#swagger.description = 'Accepts multiple images + metadata/consents and creates a managed group with management token.'
|
|
||||||
#swagger.consumes = ['multipart/form-data']
|
|
||||||
#swagger.responses[200] = { description: 'Batch upload successful (returns management token)' }
|
|
||||||
#swagger.responses[400] = { description: 'Missing files or workshop consent' }
|
|
||||||
#swagger.responses[500] = { description: 'Unexpected server error' }
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
// Überprüfe ob Dateien hochgeladen wurden
|
// Überprüfe ob Dateien hochgeladen wurden
|
||||||
if (!req.files || !req.files.images) {
|
if (!req.files || !req.files.images) {
|
||||||
|
|
@ -121,12 +108,6 @@ router.post('/upload/batch', async (req, res) => {
|
||||||
consents = {};
|
consents = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge separate form fields into metadata (backwards compatibility)
|
|
||||||
if (req.body.year) metadata.year = parseInt(req.body.year);
|
|
||||||
if (req.body.title) metadata.title = req.body.title;
|
|
||||||
if (req.body.name) metadata.name = req.body.name;
|
|
||||||
if (req.body.description) metadata.description = req.body.description;
|
|
||||||
|
|
||||||
// Validiere Workshop Consent (Pflichtfeld)
|
// Validiere Workshop Consent (Pflichtfeld)
|
||||||
if (!consents.workshopConsent) {
|
if (!consents.workshopConsent) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -239,22 +220,6 @@ router.post('/upload/batch', async (req, res) => {
|
||||||
|
|
||||||
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
|
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
|
||||||
|
|
||||||
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
|
||||||
if (telegramService.isAvailable()) {
|
|
||||||
telegramService.sendUploadNotification({
|
|
||||||
name: group.name,
|
|
||||||
year: group.year,
|
|
||||||
title: group.title,
|
|
||||||
imageCount: files.length,
|
|
||||||
workshopConsent: consents.workshopConsent,
|
|
||||||
socialMediaConsents: consents.socialMediaConsents || [],
|
|
||||||
token: createResult.managementToken
|
|
||||||
}).catch(err => {
|
|
||||||
// Fehler loggen, aber Upload nicht fehlschlagen lassen
|
|
||||||
console.error('[Telegram] Upload notification failed:', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erfolgreiche Antwort mit Management-Token
|
// Erfolgreiche Antwort mit Management-Token
|
||||||
res.json({
|
res.json({
|
||||||
groupId: group.groupId,
|
groupId: group.groupId,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ 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
|
||||||
|
|
@ -58,37 +56,16 @@ router.get('/social-media/platforms', async (req, res) => {
|
||||||
// Group Consents
|
// Group Consents
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /groups/:groupId/consents
|
||||||
|
* Speichere oder aktualisiere Consents für eine Gruppe
|
||||||
|
*
|
||||||
|
* Body: {
|
||||||
|
* workshopConsent: boolean,
|
||||||
|
* socialMediaConsents: [{ platformId: number, consented: boolean }]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
router.post('/groups/:groupId/consents', async (req, res) => {
|
router.post('/groups/:groupId/consents', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Consent Management']
|
|
||||||
#swagger.summary = 'Save or update consents for a group'
|
|
||||||
#swagger.description = 'Store workshop consent and social media consents for a specific group'
|
|
||||||
#swagger.parameters['groupId'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Group ID',
|
|
||||||
example: 'abc123def456'
|
|
||||||
}
|
|
||||||
#swagger.parameters['body'] = {
|
|
||||||
in: 'body',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: [
|
|
||||||
{ platformId: 1, consented: true },
|
|
||||||
{ platformId: 2, consented: false }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = {
|
|
||||||
description: 'Consents saved successfully',
|
|
||||||
schema: { success: true, message: 'Consents saved successfully' }
|
|
||||||
}
|
|
||||||
#swagger.responses[400] = {
|
|
||||||
description: 'Invalid request data'
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const { workshopConsent, socialMediaConsents } = req.body;
|
const { workshopConsent, socialMediaConsents } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,6 @@ const router = Router();
|
||||||
* description: File not found
|
* description: File not found
|
||||||
*/
|
*/
|
||||||
router.get('/download/:id', (req, res) => {
|
router.get('/download/:id', (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Download']
|
|
||||||
#swagger.summary = 'Download original image'
|
|
||||||
#swagger.parameters['id'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Filename of the uploaded image'
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = { description: 'Binary image response' }
|
|
||||||
#swagger.responses[404] = { description: 'File not found' }
|
|
||||||
*/
|
|
||||||
const filePath = path.join(__dirname, '..', UPLOAD_FS_DIR, req.params.id);
|
const filePath = path.join(__dirname, '..', UPLOAD_FS_DIR, req.params.id);
|
||||||
res.download(filePath);
|
res.download(filePath);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,54 @@ const MigrationService = require('../services/MigrationService');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /groups:
|
||||||
|
* get:
|
||||||
|
* tags: [Groups]
|
||||||
|
* summary: Get all approved groups with images
|
||||||
|
* description: Returns all approved groups with their images for public slideshow display. Automatically triggers migration if needed.
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of approved groups
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* groups:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* groupId:
|
||||||
|
* type: string
|
||||||
|
* example: "cTV24Yn-a"
|
||||||
|
* year:
|
||||||
|
* type: integer
|
||||||
|
* example: 2024
|
||||||
|
* title:
|
||||||
|
* type: string
|
||||||
|
* example: "Familie Mueller"
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* approved:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* images:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* totalCount:
|
||||||
|
* type: integer
|
||||||
|
* example: 73
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
// Alle Gruppen abrufen (für Slideshow mit vollständigen Bilddaten)
|
// Alle Gruppen abrufen (für Slideshow mit vollständigen Bilddaten)
|
||||||
router.get('/groups', async (req, res) => {
|
router.get('/groups', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Public Groups']
|
|
||||||
#swagger.summary = 'Get approved groups with images'
|
|
||||||
#swagger.description = 'Returns all approved groups (slideshow feed). Automatically triggers JSON→SQLite migration if required.'
|
|
||||||
#swagger.responses[200] = {
|
|
||||||
description: 'List of approved groups',
|
|
||||||
schema: {
|
|
||||||
groups: [{ groupId: 'cTV24Yn-a', title: 'Familie Mueller' }],
|
|
||||||
totalCount: 73
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#swagger.responses[500] = { description: 'Server error' }
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
// Auto-Migration beim ersten Zugriff
|
// Auto-Migration beim ersten Zugriff
|
||||||
const migrationStatus = await MigrationService.getMigrationStatus();
|
const migrationStatus = await MigrationService.getMigrationStatus();
|
||||||
|
|
@ -42,21 +75,52 @@ router.get('/groups', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /groups/{groupId}:
|
||||||
|
* get:
|
||||||
|
* tags: [Groups]
|
||||||
|
* summary: Get a specific approved group by ID
|
||||||
|
* description: Returns details of a single approved group with all its images
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: groupId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: "cTV24Yn-a"
|
||||||
|
* description: Unique identifier of the group
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Group details
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* groupId:
|
||||||
|
* type: string
|
||||||
|
* year:
|
||||||
|
* type: integer
|
||||||
|
* title:
|
||||||
|
* type: string
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* approved:
|
||||||
|
* type: boolean
|
||||||
|
* images:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* 404:
|
||||||
|
* description: Group not found
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
// Einzelne Gruppe abrufen (nur freigegebene)
|
// Einzelne Gruppe abrufen (nur freigegebene)
|
||||||
router.get('/groups/:groupId', async (req, res) => {
|
router.get('/groups/:groupId', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Public Groups']
|
|
||||||
#swagger.summary = 'Get approved group by ID'
|
|
||||||
#swagger.parameters['groupId'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Public groupId (e.g. cTV24Yn-a)'
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = { description: 'Group payload (images + metadata)' }
|
|
||||||
#swagger.responses[404] = { description: 'Group not found or not approved' }
|
|
||||||
#swagger.responses[500] = { description: 'Server error' }
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const group = await GroupRepository.getGroupById(groupId);
|
const group = await GroupRepository.getGroupById(groupId);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
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');
|
||||||
|
|
@ -15,12 +13,10 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@ const deletionLogRepository = require('../repositories/DeletionLogRepository');
|
||||||
const dbManager = require('../database/DatabaseManager');
|
const dbManager = require('../database/DatabaseManager');
|
||||||
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
|
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
|
||||||
const auditLogMiddleware = require('../middlewares/auditLog');
|
const auditLogMiddleware = require('../middlewares/auditLog');
|
||||||
const TelegramNotificationService = require('../services/TelegramNotificationService');
|
|
||||||
|
|
||||||
// Singleton-Instanz des Telegram Service
|
|
||||||
const telegramService = new TelegramNotificationService();
|
|
||||||
|
|
||||||
// Apply middleware to all management routes
|
// Apply middleware to all management routes
|
||||||
router.use(rateLimitMiddleware);
|
router.use(rateLimitMiddleware);
|
||||||
|
|
@ -215,20 +211,6 @@ router.put('/:token/consents', async (req, res) => {
|
||||||
[newValue, groupData.groupId]
|
[newValue, groupData.groupId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
|
||||||
if (telegramService.isAvailable()) {
|
|
||||||
telegramService.sendConsentChangeNotification({
|
|
||||||
name: groupData.name,
|
|
||||||
year: groupData.year,
|
|
||||||
title: groupData.title,
|
|
||||||
consentType: 'workshop',
|
|
||||||
action: action,
|
|
||||||
newValue: newValue === 1
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[Telegram] Consent change notification failed:', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Workshop consent ${action}d successfully`,
|
message: `Workshop consent ${action}d successfully`,
|
||||||
|
|
@ -281,26 +263,6 @@ router.put('/:token/consents', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
|
||||||
if (telegramService.isAvailable()) {
|
|
||||||
// Hole Platform-Name für Benachrichtigung
|
|
||||||
const platform = await dbManager.get(
|
|
||||||
'SELECT platform_name FROM social_media_platforms WHERE id = ?',
|
|
||||||
[platformId]
|
|
||||||
);
|
|
||||||
|
|
||||||
telegramService.sendConsentChangeNotification({
|
|
||||||
name: groupData.name,
|
|
||||||
year: groupData.year,
|
|
||||||
title: groupData.title,
|
|
||||||
consentType: 'social_media',
|
|
||||||
action: action,
|
|
||||||
platform: platform ? platform.platform_name : `Platform ${platformId}`
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[Telegram] Consent change notification failed:', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Social media consent ${action}d successfully`,
|
message: `Social media consent ${action}d successfully`,
|
||||||
|
|
@ -1045,18 +1007,6 @@ router.delete('/:token', async (req, res) => {
|
||||||
|
|
||||||
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
|
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
|
||||||
|
|
||||||
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
|
||||||
if (telegramService.isAvailable()) {
|
|
||||||
telegramService.sendGroupDeletedNotification({
|
|
||||||
name: groupData.name,
|
|
||||||
year: groupData.year,
|
|
||||||
title: groupData.title,
|
|
||||||
imageCount: imageCount
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[Telegram] Group deletion notification failed:', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Group and all associated data deleted successfully',
|
message: 'Group and all associated data deleted successfully',
|
||||||
|
|
@ -1076,36 +1026,18 @@ router.delete('/:token', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/manage/:token/reorder
|
||||||
|
* Reorder images within the managed group (token-based access)
|
||||||
|
*
|
||||||
|
* @param {string} token - Management token (UUID v4)
|
||||||
|
* @param {number[]} imageIds - Array of image IDs in new order
|
||||||
|
* @returns {Object} Success status and updated image count
|
||||||
|
* @throws {400} Invalid token format or imageIds
|
||||||
|
* @throws {404} Token not found or group deleted
|
||||||
|
* @throws {500} Server error
|
||||||
|
*/
|
||||||
router.put('/:token/reorder', async (req, res) => {
|
router.put('/:token/reorder', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Management Portal']
|
|
||||||
#swagger.summary = 'Reorder images in group'
|
|
||||||
#swagger.description = 'Reorder images within the managed group (token-based access)'
|
|
||||||
#swagger.parameters['token'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Management token (UUID v4)',
|
|
||||||
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
||||||
}
|
|
||||||
#swagger.parameters['body'] = {
|
|
||||||
in: 'body',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
imageIds: [1, 3, 2, 4]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = {
|
|
||||||
description: 'Images reordered successfully',
|
|
||||||
schema: { success: true, updatedCount: 4 }
|
|
||||||
}
|
|
||||||
#swagger.responses[400] = {
|
|
||||||
description: 'Invalid token format or imageIds'
|
|
||||||
}
|
|
||||||
#swagger.responses[404] = {
|
|
||||||
description: 'Token not found or group deleted'
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { imageIds } = req.body;
|
const { imageIds } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -36,7 +35,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, requireCsrf, async (req, res) => {
|
router.post('/migrate', requireAdminAuth, async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['System Migration']
|
#swagger.tags = ['System Migration']
|
||||||
#swagger.summary = 'Manually trigger migration'
|
#swagger.summary = 'Manually trigger migration'
|
||||||
|
|
@ -67,7 +66,7 @@ router.post('/migrate', requireAdminAuth, requireCsrf, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/rollback', requireAdminAuth, requireCsrf, async (req, res) => {
|
router.post('/rollback', requireAdminAuth, async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['System Migration']
|
#swagger.tags = ['System Migration']
|
||||||
#swagger.summary = 'Rollback to JSON'
|
#swagger.summary = 'Rollback to JSON'
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -70,20 +65,6 @@ router.use(requireCsrf);
|
||||||
* description: Server error during reordering
|
* description: Server error during reordering
|
||||||
*/
|
*/
|
||||||
router.put('/:groupId/reorder', async (req, res) => {
|
router.put('/:groupId/reorder', async (req, res) => {
|
||||||
/*
|
|
||||||
#swagger.tags = ['Admin - Groups Moderation']
|
|
||||||
#swagger.summary = 'Reorder images within a group'
|
|
||||||
#swagger.parameters['groupId'] = {
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
type: 'string',
|
|
||||||
description: 'Admin groupId'
|
|
||||||
}
|
|
||||||
#swagger.responses[200] = { description: 'Order updated successfully' }
|
|
||||||
#swagger.responses[400] = { description: 'Validation error' }
|
|
||||||
#swagger.responses[404] = { description: 'Group not found' }
|
|
||||||
#swagger.responses[500] = { description: 'Internal server error' }
|
|
||||||
*/
|
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const { imageIds } = req.body;
|
const { imageIds } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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' },
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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) => {
|
|
||||||
/*
|
|
||||||
#swagger.tags = ['Consent Management']
|
|
||||||
#swagger.summary = 'List active social media platforms'
|
|
||||||
#swagger.description = 'Public endpoint that exposes the available platforms for consent selection on the upload form.'
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|
@ -6,7 +6,6 @@ const path = require('path');
|
||||||
const ImagePreviewService = require('../services/ImagePreviewService');
|
const ImagePreviewService = require('../services/ImagePreviewService');
|
||||||
const groupRepository = require('../repositories/GroupRepository');
|
const groupRepository = require('../repositories/GroupRepository');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { publicUploadLimiter } = require('../middlewares/rateLimiter');
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -16,7 +15,7 @@ router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR)
|
||||||
// Serve preview images via URL /previews but store files under data/previews
|
// Serve preview images via URL /previews but store files under data/previews
|
||||||
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
|
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
|
||||||
|
|
||||||
router.post('/upload', publicUploadLimiter, async (req, res) => {
|
router.post('/upload', async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Upload']
|
#swagger.tags = ['Upload']
|
||||||
#swagger.summary = 'Upload a single image and create a new group'
|
#swagger.summary = 'Upload a single image and create a new group'
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
#!/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,20 +1,29 @@
|
||||||
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 TelegramNotificationService = require('./services/TelegramNotificationService');
|
|
||||||
|
|
||||||
// Singleton-Instanz des Telegram Service
|
// Dev: Auto-generate OpenAPI spec on server start (skip in test mode)
|
||||||
const telegramService = new TelegramNotificationService();
|
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Generating OpenAPI specification...');
|
||||||
|
require('./generate-openapi');
|
||||||
|
console.log('✓ OpenAPI spec generated');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to generate OpenAPI spec:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dev: Swagger UI (mount only in non-production) — require lazily
|
// Dev: Swagger UI (mount only in non-production)
|
||||||
let swaggerUi = null;
|
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 {
|
||||||
|
|
@ -24,42 +33,10 @@ class Server {
|
||||||
constructor(port) {
|
constructor(port) {
|
||||||
this._port = port;
|
this._port = port;
|
||||||
this._app = express();
|
this._app = express();
|
||||||
const trustProxyHops = Number.parseInt(process.env.TRUST_PROXY_HOPS ?? '1', 10);
|
|
||||||
if (!Number.isNaN(trustProxyHops) && trustProxyHops > 0) {
|
|
||||||
this._app.set('trust proxy', trustProxyHops);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateOpenApiSpecIfNeeded() {
|
|
||||||
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const generateOpenApi = require('./generate-openapi');
|
|
||||||
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();
|
||||||
|
|
@ -71,30 +48,16 @@ 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) {
|
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
|
||||||
const swaggerDocument = this.loadSwaggerDocument();
|
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||||
if (swaggerDocument) {
|
console.log('ℹ️ Swagger UI mounted at /api/docs (dev only)');
|
||||||
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
|
||||||
console.log('ℹ️ Swagger UI mounted at /api/docs (dev only)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this._app.listen(this._port, () => {
|
this._app.listen(this._port, () => {
|
||||||
console.log(`✅ Server läuft auf Port ${this._port}`);
|
console.log(`✅ Server läuft auf Port ${this._port}`);
|
||||||
console.log(`📊 SQLite Datenbank aktiv`);
|
console.log(`📊 SQLite Datenbank aktiv`);
|
||||||
|
|
||||||
// Speichere SchedulerService in app für Admin-Endpoints
|
|
||||||
this._app.set('schedulerService', SchedulerService);
|
|
||||||
|
|
||||||
// Starte Scheduler für automatisches Cleanup
|
// Starte Scheduler für automatisches Cleanup
|
||||||
SchedulerService.start();
|
SchedulerService.start();
|
||||||
|
|
||||||
// Teste Telegram-Service (optional, nur in Development wenn aktiviert)
|
|
||||||
if (process.env.NODE_ENV === 'development'
|
|
||||||
&& process.env.TELEGRAM_SEND_TEST_ON_START === 'true'
|
|
||||||
&& telegramService.isAvailable()) {
|
|
||||||
telegramService.sendTestMessage()
|
|
||||||
.catch(err => console.error('[Telegram] Test message failed:', err.message));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 Fehler beim Serverstart:', error);
|
console.error('💥 Fehler beim Serverstart:', error);
|
||||||
|
|
@ -114,11 +77,8 @@ class Server {
|
||||||
this._app.use('/upload', express.static( __dirname + '/upload'));
|
this._app.use('/upload', express.static( __dirname + '/upload'));
|
||||||
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
|
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production' && swaggerUi) {
|
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
|
||||||
const swaggerDocument = this.loadSwaggerDocument();
|
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||||
if (swaggerDocument) {
|
|
||||||
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this._app;
|
return this._app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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,11 +1,9 @@
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const GroupCleanupService = require('./GroupCleanupService');
|
const GroupCleanupService = require('./GroupCleanupService');
|
||||||
const TelegramNotificationService = require('./TelegramNotificationService');
|
|
||||||
|
|
||||||
class SchedulerService {
|
class SchedulerService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
this.telegramService = new TelegramNotificationService();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
|
@ -32,35 +30,7 @@ class SchedulerService {
|
||||||
|
|
||||||
this.tasks.push(cleanupTask);
|
this.tasks.push(cleanupTask);
|
||||||
|
|
||||||
// Telegram Warning-Job: Jeden Tag um 09:00 Uhr (1 Stunde vor Cleanup)
|
console.log('✓ Scheduler started - Daily cleanup at 10:00 AM (Europe/Berlin)');
|
||||||
const telegramWarningTask = cron.schedule('0 9 * * *', async () => {
|
|
||||||
console.log('[Scheduler] Running daily Telegram deletion warning at 09:00 AM...');
|
|
||||||
try {
|
|
||||||
if (this.telegramService.isAvailable()) {
|
|
||||||
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
|
|
||||||
|
|
||||||
if (groupsForDeletion && groupsForDeletion.length > 0) {
|
|
||||||
await this.telegramService.sendDeletionWarning(groupsForDeletion);
|
|
||||||
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
|
|
||||||
} else {
|
|
||||||
console.log('[Scheduler] No groups pending deletion');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[Scheduler] Telegram service not available, skipping warning');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Scheduler] Telegram warning task failed:', error);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
scheduled: true,
|
|
||||||
timezone: "Europe/Berlin"
|
|
||||||
});
|
|
||||||
|
|
||||||
this.tasks.push(telegramWarningTask);
|
|
||||||
|
|
||||||
console.log('✓ Scheduler started:');
|
|
||||||
console.log(' - Daily cleanup at 10:00 AM (Europe/Berlin)');
|
|
||||||
console.log(' - Daily Telegram warning at 09:00 AM (Europe/Berlin)');
|
|
||||||
|
|
||||||
// Für Development: Manueller Trigger
|
// Für Development: Manueller Trigger
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|
@ -80,42 +50,6 @@ class SchedulerService {
|
||||||
console.log('[Scheduler] Manual cleanup triggered...');
|
console.log('[Scheduler] Manual cleanup triggered...');
|
||||||
return await GroupCleanupService.performScheduledCleanup();
|
return await GroupCleanupService.performScheduledCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Für Development: Manueller Telegram-Warning-Trigger
|
|
||||||
async triggerTelegramWarningNow() {
|
|
||||||
console.log('[Scheduler] Manual Telegram warning triggered...');
|
|
||||||
try {
|
|
||||||
if (!this.telegramService.isAvailable()) {
|
|
||||||
console.log('[Scheduler] Telegram service not available');
|
|
||||||
return { success: false, message: 'Telegram service not available' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
|
|
||||||
|
|
||||||
if (!groupsForDeletion || groupsForDeletion.length === 0) {
|
|
||||||
console.log('[Scheduler] No groups pending deletion');
|
|
||||||
return { success: true, message: 'No groups pending deletion', groupCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.telegramService.sendDeletionWarning(groupsForDeletion);
|
|
||||||
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Warning sent for ${groupsForDeletion.length} groups`,
|
|
||||||
groupCount: groupsForDeletion.length,
|
|
||||||
groups: groupsForDeletion.map(g => ({
|
|
||||||
groupId: g.groupId,
|
|
||||||
name: g.name,
|
|
||||||
year: g.year,
|
|
||||||
uploadDate: g.uploadDate
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Scheduler] Manual Telegram warning failed:', error);
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SchedulerService();
|
module.exports = new SchedulerService();
|
||||||
|
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
const TelegramBot = require('node-telegram-bot-api');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TelegramNotificationService
|
|
||||||
*
|
|
||||||
* Versendet automatische Benachrichtigungen über Telegram an die Werkstatt-Gruppe.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Upload-Benachrichtigungen (Phase 3)
|
|
||||||
* - Consent-Änderungs-Benachrichtigungen (Phase 4)
|
|
||||||
* - Gruppen-Lösch-Benachrichtigungen (Phase 4)
|
|
||||||
* - Tägliche Lösch-Warnungen (Phase 5)
|
|
||||||
*
|
|
||||||
* Phase 2: Backend-Service Integration (Basic Setup)
|
|
||||||
*/
|
|
||||||
class TelegramNotificationService {
|
|
||||||
constructor() {
|
|
||||||
this.enabled = process.env.TELEGRAM_ENABLED === 'true';
|
|
||||||
this.botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
||||||
this.chatId = process.env.TELEGRAM_CHAT_ID;
|
|
||||||
this.bot = null;
|
|
||||||
|
|
||||||
if (this.enabled) {
|
|
||||||
this.initialize();
|
|
||||||
} else {
|
|
||||||
console.log('[Telegram] Service disabled (TELEGRAM_ENABLED=false)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialisiert den Telegram Bot
|
|
||||||
*/
|
|
||||||
initialize() {
|
|
||||||
try {
|
|
||||||
if (!this.botToken) {
|
|
||||||
throw new Error('TELEGRAM_BOT_TOKEN is not defined');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.chatId) {
|
|
||||||
throw new Error('TELEGRAM_CHAT_ID is not defined');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bot = new TelegramBot(this.botToken, { polling: false });
|
|
||||||
console.log('[Telegram] Service initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Initialization failed:', error.message);
|
|
||||||
this.enabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft, ob der Service verfügbar ist
|
|
||||||
*/
|
|
||||||
isAvailable() {
|
|
||||||
return this.enabled && this.bot !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sendet eine Test-Nachricht
|
|
||||||
*
|
|
||||||
* @returns {Promise<Object>} Telegram API Response
|
|
||||||
*/
|
|
||||||
async sendTestMessage() {
|
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.log('[Telegram] Service not available, skipping test message');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timestamp = new Date().toLocaleString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
});
|
|
||||||
|
|
||||||
const message = `
|
|
||||||
🤖 Telegram Service Test
|
|
||||||
|
|
||||||
Service erfolgreich initialisiert!
|
|
||||||
|
|
||||||
Zeitstempel: ${timestamp}
|
|
||||||
Environment: ${process.env.NODE_ENV || 'development'}
|
|
||||||
|
|
||||||
---
|
|
||||||
ℹ️ Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const response = await this.bot.sendMessage(this.chatId, message);
|
|
||||||
console.log('[Telegram] Test message sent successfully');
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Failed to send test message:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 3: Sendet Benachrichtigung bei neuem Upload
|
|
||||||
*
|
|
||||||
* @param {Object} groupData - Gruppen-Informationen
|
|
||||||
* @param {string} groupData.name - Name des Uploaders
|
|
||||||
* @param {number} groupData.year - Jahr der Gruppe
|
|
||||||
* @param {string} groupData.title - Titel der Gruppe
|
|
||||||
* @param {number} groupData.imageCount - Anzahl hochgeladener Bilder
|
|
||||||
* @param {boolean} groupData.workshopConsent - Workshop-Consent Status
|
|
||||||
* @param {Array<string>} groupData.socialMediaConsents - Social Media Plattformen
|
|
||||||
* @param {string} groupData.token - Management-Token
|
|
||||||
*/
|
|
||||||
async sendUploadNotification(groupData) {
|
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.log('[Telegram] Service not available, skipping upload notification');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const workshopIcon = groupData.workshopConsent ? '✅' : '❌';
|
|
||||||
const socialMediaIcons = this.formatSocialMediaIcons(groupData.socialMediaConsents);
|
|
||||||
|
|
||||||
const message = `
|
|
||||||
📸 Neuer Upload!
|
|
||||||
|
|
||||||
Uploader: ${groupData.name}
|
|
||||||
Bilder: ${groupData.imageCount}
|
|
||||||
Gruppe: ${groupData.year} - ${groupData.title}
|
|
||||||
|
|
||||||
Workshop: ${workshopIcon} ${groupData.workshopConsent ? 'Ja' : 'Nein'}
|
|
||||||
Social Media: ${socialMediaIcons || '❌ Keine'}
|
|
||||||
|
|
||||||
🔗 Zur Freigabe: ${this.getAdminUrl()}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const response = await this.bot.sendMessage(this.chatId, message);
|
|
||||||
console.log(`[Telegram] Upload notification sent for group: ${groupData.title}`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Failed to send upload notification:', error.message);
|
|
||||||
// Fehler loggen, aber nicht werfen - Upload soll nicht fehlschlagen wegen Telegram
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 4: Sendet Benachrichtigung bei Consent-Änderung
|
|
||||||
*
|
|
||||||
* @param {Object} changeData - Änderungs-Informationen
|
|
||||||
* @param {string} changeData.name - Name des Uploaders
|
|
||||||
* @param {number} changeData.year - Jahr
|
|
||||||
* @param {string} changeData.title - Titel
|
|
||||||
* @param {string} changeData.consentType - 'workshop' oder 'social_media'
|
|
||||||
* @param {string} changeData.action - 'revoke' oder 'restore'
|
|
||||||
* @param {string} [changeData.platform] - Plattform-Name (nur bei social_media)
|
|
||||||
* @param {boolean} [changeData.newValue] - Neuer Wert (nur bei workshop)
|
|
||||||
*/
|
|
||||||
async sendConsentChangeNotification(changeData) {
|
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.log('[Telegram] Service not available, skipping consent change notification');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { name, year, title, consentType, action, platform, newValue } = changeData;
|
|
||||||
|
|
||||||
let changeDescription;
|
|
||||||
if (consentType === 'workshop') {
|
|
||||||
const icon = newValue ? '✅' : '❌';
|
|
||||||
const status = newValue ? 'Ja' : 'Nein';
|
|
||||||
const actionText = action === 'revoke' ? 'widerrufen' : 'wiederhergestellt';
|
|
||||||
changeDescription = `Workshop-Consent ${actionText}\nNeuer Status: ${icon} ${status}`;
|
|
||||||
} else if (consentType === 'social_media') {
|
|
||||||
const actionText = action === 'revoke' ? 'widerrufen' : 'erteilt';
|
|
||||||
changeDescription = `Social Media Consent ${actionText}\nPlattform: ${platform}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = `
|
|
||||||
⚙️ Consent-Änderung
|
|
||||||
|
|
||||||
Gruppe: ${year} - ${title}
|
|
||||||
Uploader: ${name}
|
|
||||||
|
|
||||||
${changeDescription}
|
|
||||||
|
|
||||||
🔗 Details: ${this.getAdminUrl()}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const response = await this.bot.sendMessage(this.chatId, message);
|
|
||||||
console.log(`[Telegram] Consent change notification sent for: ${title}`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Failed to send consent change notification:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 4: Sendet Benachrichtigung bei Gruppen-Löschung durch User
|
|
||||||
*
|
|
||||||
* @param {Object} groupData - Gruppen-Informationen
|
|
||||||
*/
|
|
||||||
async sendGroupDeletedNotification(groupData) {
|
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.log('[Telegram] Service not available, skipping group deleted notification');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = `
|
|
||||||
⚙️ User-Änderung
|
|
||||||
|
|
||||||
Aktion: Gruppe gelöscht
|
|
||||||
Gruppe: ${groupData.year} - ${groupData.title}
|
|
||||||
Uploader: ${groupData.name}
|
|
||||||
Bilder: ${groupData.imageCount}
|
|
||||||
|
|
||||||
ℹ️ User hat Gruppe selbst über Management-Link gelöscht
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const response = await this.bot.sendMessage(this.chatId, message);
|
|
||||||
console.log(`[Telegram] Group deleted notification sent for: ${groupData.title}`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Failed to send group deleted notification:', error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 5: Sendet tägliche Warnung für bevorstehende Löschungen
|
|
||||||
*
|
|
||||||
* @param {Array<Object>} groupsList - Liste der zu löschenden Gruppen
|
|
||||||
*/
|
|
||||||
async sendDeletionWarning(groupsList) {
|
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.log('[Telegram] Service not available, skipping deletion warning');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groupsList || groupsList.length === 0) {
|
|
||||||
console.log('[Telegram] No groups pending deletion, skipping warning');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let groupsText = groupsList.map((group, index) => {
|
|
||||||
const uploadDate = new Date(group.created_at).toLocaleDateString('de-DE');
|
|
||||||
return `${index + 1}. ${group.year} - ${group.title}
|
|
||||||
Uploader: ${group.name}
|
|
||||||
Bilder: ${group.imageCount}
|
|
||||||
Hochgeladen: ${uploadDate}`;
|
|
||||||
}).join('\n\n');
|
|
||||||
|
|
||||||
const message = `
|
|
||||||
⏰ Löschung in 24 Stunden!
|
|
||||||
|
|
||||||
Folgende Gruppen werden morgen automatisch gelöscht:
|
|
||||||
|
|
||||||
${groupsText}
|
|
||||||
|
|
||||||
💡 Jetzt freigeben oder Freigabe bleibt aus!
|
|
||||||
🔗 Zur Moderation: ${this.getAdminUrl()}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const response = await this.bot.sendMessage(this.chatId, message);
|
|
||||||
console.log(`[Telegram] Deletion warning sent for ${groupsList.length} groups`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Telegram] Failed to send deletion warning:', error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Helper Methods
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formatiert Social Media Consents als Icons
|
|
||||||
*
|
|
||||||
* @param {Array<string>} platforms - Liste der Plattformen
|
|
||||||
* @returns {string} Formatierter String mit Icons
|
|
||||||
*/
|
|
||||||
formatSocialMediaIcons(platforms) {
|
|
||||||
if (!platforms || platforms.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
'facebook': '📘 Facebook',
|
|
||||||
'instagram': '📷 Instagram',
|
|
||||||
'tiktok': '🎵 TikTok'
|
|
||||||
};
|
|
||||||
|
|
||||||
return platforms.map(p => iconMap[p.toLowerCase()] || p).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gibt die Admin-URL zurück
|
|
||||||
*
|
|
||||||
* @returns {string} Admin-Panel URL
|
|
||||||
*/
|
|
||||||
getAdminUrl() {
|
|
||||||
const host = process.env.INTERNAL_HOST || 'internal.hobbyhimmel.de';
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
const protocol = isProduction ? 'https' : 'http';
|
|
||||||
const port = isProduction ? '' : ':3000';
|
|
||||||
return `${protocol}://${host}${port}/moderation`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TelegramNotificationService;
|
|
||||||
|
|
@ -1,34 +1,55 @@
|
||||||
const { getRequest } = require('../testServer');
|
const { getRequest } = require('../testServer');
|
||||||
const { getAdminSession } = require('../utils/adminSession');
|
|
||||||
|
|
||||||
describe('Admin Auth Middleware', () => {
|
describe('Admin Auth Middleware', () => {
|
||||||
describe('Without Session', () => {
|
describe('Without Auth Token', () => {
|
||||||
it('should reject requests without session cookie', async () => {
|
it('should reject requests without Authorization header', 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).toHaveProperty('reason', 'SESSION_REQUIRED');
|
expect(response.body.message).toContain('Authorization header fehlt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject requests with invalid Bearer format', async () => {
|
||||||
|
const response = await getRequest()
|
||||||
|
.get('/api/admin/deletion-log')
|
||||||
|
.set('Authorization', 'InvalidFormat token123')
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('error');
|
||||||
|
expect(response.body.message).toContain('Ungültiges Authorization Format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject requests with wrong token', async () => {
|
||||||
|
const response = await getRequest()
|
||||||
|
.get('/api/admin/deletion-log')
|
||||||
|
.set('Authorization', 'Bearer wrong-token-123')
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('error');
|
||||||
|
expect(response.body.message).toContain('Ungültiger Admin-Token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('With Valid Session', () => {
|
describe('With Valid Auth Token', () => {
|
||||||
let adminSession;
|
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-123';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(() => {
|
||||||
adminSession = await getAdminSession();
|
// Set test admin key
|
||||||
|
process.env.ADMIN_API_KEY = validToken;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow access with valid session', async () => {
|
it('should allow access with valid Bearer token', async () => {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.get('/api/admin/deletion-log')
|
.get('/api/admin/deletion-log')
|
||||||
|
.set('Authorization', `Bearer ${validToken}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toHaveProperty('success');
|
expect(response.body).toHaveProperty('success');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow access to multiple admin endpoints', async () => {
|
it('should protect all 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',
|
||||||
|
|
@ -37,8 +58,9 @@ describe('Admin Auth Middleware', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.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('reason', 'SESSION_REQUIRED');
|
expect(response.body).toHaveProperty('error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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 require a logged-in admin session
|
// This test would need a valid admin token
|
||||||
// For now, we just ensure the endpoint rejects unauthenticated access
|
// For now, we just test that invalid params are rejected
|
||||||
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,17 +1,13 @@
|
||||||
const { getRequest } = require('../testServer');
|
const { getRequest } = require('../testServer');
|
||||||
const { getAdminSession } = require('../utils/adminSession');
|
|
||||||
|
|
||||||
describe('Consent Management API', () => {
|
describe('Consent Management API', () => {
|
||||||
let adminSession;
|
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-12345';
|
||||||
|
|
||||||
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 adminSession.agent
|
const response = await getRequest()
|
||||||
.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);
|
||||||
|
|
||||||
|
|
@ -19,8 +15,9 @@ describe('Consent Management API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include platform metadata', async () => {
|
it('should include platform metadata', async () => {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.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];
|
||||||
|
|
@ -33,14 +30,16 @@ 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 adminSession.agent
|
await getRequest()
|
||||||
.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 adminSession.agent
|
await getRequest()
|
||||||
.get('/api/admin/groups/../../../etc/passwd/consents')
|
.get('/api/admin/groups/../../../etc/passwd/consents')
|
||||||
|
.set('Authorization', `Bearer ${validToken}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -54,9 +53,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 adminSession.agent
|
const response = await getRequest()
|
||||||
.post('/api/admin/groups/test-group-id/consents')
|
.post('/api/admin/groups/test-group-id/consents')
|
||||||
.set('X-CSRF-Token', adminSession.csrfToken)
|
.set('Authorization', `Bearer ${validToken}`)
|
||||||
.send({})
|
.send({})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
|
|
@ -66,8 +65,9 @@ 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 adminSession.agent
|
const response = await getRequest()
|
||||||
.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,8 +77,9 @@ describe('Consent Management API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept platform filter', async () => {
|
it('should accept platform filter', async () => {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.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');
|
||||||
|
|
@ -86,8 +87,9 @@ describe('Consent Management API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept consent filter', async () => {
|
it('should accept consent filter', async () => {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.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');
|
||||||
|
|
@ -103,8 +105,9 @@ 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 adminSession.agent
|
const response = await getRequest()
|
||||||
.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/);
|
||||||
|
|
@ -112,8 +115,9 @@ describe('Consent Management API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include CSV header', async () => {
|
it('should include CSV header', async () => {
|
||||||
const response = await adminSession.agent
|
const response = await getRequest()
|
||||||
.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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
/**
|
|
||||||
* Integration Tests für Telegram Upload-Benachrichtigungen
|
|
||||||
*
|
|
||||||
* Phase 3: Upload-Benachrichtigungen
|
|
||||||
*
|
|
||||||
* Diese Tests prüfen die Integration zwischen Upload-Route und Telegram-Service
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { getRequest } = require('../testServer');
|
|
||||||
|
|
||||||
describe('Telegram Upload Notifications (Integration)', () => {
|
|
||||||
let TelegramNotificationService;
|
|
||||||
let sendUploadNotificationSpy;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// Spy auf TelegramNotificationService
|
|
||||||
TelegramNotificationService = require('../../src/services/TelegramNotificationService');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Spy auf sendUploadNotification erstellen
|
|
||||||
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification')
|
|
||||||
.mockResolvedValue({ message_id: 42 });
|
|
||||||
|
|
||||||
// isAvailable() immer true zurückgeben für Tests
|
|
||||||
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
|
|
||||||
.mockReturnValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore alle Spys
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /api/upload/batch', () => {
|
|
||||||
const testImagePath = path.join(__dirname, '../utils/test-image.jpg');
|
|
||||||
|
|
||||||
// Erstelle Test-Bild falls nicht vorhanden
|
|
||||||
beforeAll(() => {
|
|
||||||
if (!fs.existsSync(testImagePath)) {
|
|
||||||
// Erstelle 1x1 px JPEG
|
|
||||||
const buffer = Buffer.from([
|
|
||||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
|
|
||||||
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
|
|
||||||
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
|
||||||
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
|
|
||||||
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
|
|
||||||
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
|
|
||||||
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
|
|
||||||
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
|
|
||||||
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
|
|
||||||
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
|
|
||||||
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
|
|
||||||
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
|
|
||||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
|
|
||||||
0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, 0x03, 0xFF, 0xC4, 0x00, 0x14,
|
|
||||||
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
|
|
||||||
0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9
|
|
||||||
]);
|
|
||||||
fs.writeFileSync(testImagePath, buffer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte Telegram-Benachrichtigung bei erfolgreichem Upload senden', async () => {
|
|
||||||
const response = await getRequest()
|
|
||||||
.post('/api/upload/batch')
|
|
||||||
.field('year', '2024')
|
|
||||||
.field('title', 'Test Upload')
|
|
||||||
.field('name', 'Test User')
|
|
||||||
.field('consents', JSON.stringify({
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: ['instagram', 'tiktok']
|
|
||||||
}))
|
|
||||||
.attach('images', testImagePath);
|
|
||||||
|
|
||||||
// Upload sollte erfolgreich sein
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.message).toBe('Batch upload successful');
|
|
||||||
|
|
||||||
// Warte kurz auf async Telegram-Call
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Telegram-Service sollte aufgerufen worden sein
|
|
||||||
expect(sendUploadNotificationSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'Test User',
|
|
||||||
year: 2024,
|
|
||||||
title: 'Test Upload',
|
|
||||||
imageCount: 1,
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: ['instagram', 'tiktok']
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte Upload nicht fehlschlagen wenn Telegram-Service nicht verfügbar', async () => {
|
|
||||||
// Restore mock und setze isAvailable auf false
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
|
|
||||||
.mockReturnValue(false);
|
|
||||||
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification');
|
|
||||||
|
|
||||||
const response = await getRequest()
|
|
||||||
.post('/api/upload/batch')
|
|
||||||
.field('year', '2024')
|
|
||||||
.field('title', 'Test Upload')
|
|
||||||
.field('name', 'Test User')
|
|
||||||
.field('consents', JSON.stringify({
|
|
||||||
workshopConsent: false,
|
|
||||||
socialMediaConsents: []
|
|
||||||
}))
|
|
||||||
.attach('images', testImagePath);
|
|
||||||
|
|
||||||
// Upload sollte trotzdem erfolgreich sein
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.message).toBe('Batch upload successful');
|
|
||||||
|
|
||||||
// Telegram sollte nicht aufgerufen worden sein
|
|
||||||
expect(sendUploadNotificationSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte Upload nicht fehlschlagen wenn Telegram-Benachrichtigung fehlschlägt', async () => {
|
|
||||||
sendUploadNotificationSpy.mockRejectedValueOnce(
|
|
||||||
new Error('Telegram API Error')
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await getRequest()
|
|
||||||
.post('/api/upload/batch')
|
|
||||||
.field('year', '2024')
|
|
||||||
.field('title', 'Test Upload')
|
|
||||||
.field('name', 'Test User')
|
|
||||||
.field('consents', JSON.stringify({
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: []
|
|
||||||
}))
|
|
||||||
.attach('images', testImagePath);
|
|
||||||
|
|
||||||
// Upload sollte trotzdem erfolgreich sein
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.message).toBe('Batch upload successful');
|
|
||||||
|
|
||||||
// Warte auf async error handling
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Telegram wurde versucht aufzurufen
|
|
||||||
expect(sendUploadNotificationSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte korrekte Daten an Telegram-Service übergeben', async () => {
|
|
||||||
const response = await getRequest()
|
|
||||||
.post('/api/upload/batch')
|
|
||||||
.field('year', '2025')
|
|
||||||
.field('title', 'Schweißkurs November')
|
|
||||||
.field('name', 'Max Mustermann')
|
|
||||||
.field('consents', JSON.stringify({
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: ['facebook', 'instagram']
|
|
||||||
}))
|
|
||||||
.attach('images', testImagePath)
|
|
||||||
.attach('images', testImagePath);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
expect(sendUploadNotificationSpy).toHaveBeenCalledWith({
|
|
||||||
name: 'Max Mustermann',
|
|
||||||
year: 2025,
|
|
||||||
title: 'Schweißkurs November',
|
|
||||||
imageCount: 2,
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: ['facebook', 'instagram'],
|
|
||||||
token: expect.any(String)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
|
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create and initialize server
|
// Create and initialize server
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,6 @@
|
||||||
* 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
|
||||||
|
|
@ -18,6 +13,10 @@ 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,27 +4,17 @@ 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
|
||||||
*/
|
*/
|
||||||
let cachedAgent = null;
|
function getRequest() {
|
||||||
|
|
||||||
function getApp() {
|
|
||||||
const app = global.__TEST_APP__;
|
const app = global.__TEST_APP__;
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Test server not initialized. This should be handled by globalSetup.js automatically.'
|
'Test server not initialized. ' +
|
||||||
|
'This should be handled by globalSetup.js automatically.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRequest() {
|
return request(app);
|
||||||
return request(getApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAgent() {
|
|
||||||
if (!cachedAgent) {
|
|
||||||
cachedAgent = request.agent(getApp());
|
|
||||||
}
|
|
||||||
return cachedAgent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,6 +35,5 @@ async function teardownTestServer() {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
setupTestServer,
|
setupTestServer,
|
||||||
teardownTestServer,
|
teardownTestServer,
|
||||||
getRequest,
|
getRequest
|
||||||
getAgent
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
/**
|
|
||||||
* Unit Tests für TelegramNotificationService
|
|
||||||
*
|
|
||||||
* Phase 2: Basic Service Tests
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TelegramNotificationService = require('../../src/services/TelegramNotificationService');
|
|
||||||
|
|
||||||
// Mock node-telegram-bot-api komplett
|
|
||||||
jest.mock('node-telegram-bot-api');
|
|
||||||
|
|
||||||
describe('TelegramNotificationService', () => {
|
|
||||||
let originalEnv;
|
|
||||||
let TelegramBot;
|
|
||||||
let mockBotInstance;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
TelegramBot = require('node-telegram-bot-api');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Speichere originale ENV-Variablen
|
|
||||||
originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
// Setze Test-ENV
|
|
||||||
process.env.TELEGRAM_ENABLED = 'true';
|
|
||||||
process.env.TELEGRAM_BOT_TOKEN = 'test-bot-token-123';
|
|
||||||
process.env.TELEGRAM_CHAT_ID = '-1001234567890';
|
|
||||||
|
|
||||||
// Erstelle Mock-Bot-Instanz
|
|
||||||
mockBotInstance = {
|
|
||||||
sendMessage: jest.fn().mockResolvedValue({
|
|
||||||
message_id: 42,
|
|
||||||
chat: { id: -1001234567890 },
|
|
||||||
text: 'Test'
|
|
||||||
}),
|
|
||||||
getMe: jest.fn().mockResolvedValue({
|
|
||||||
id: 123456,
|
|
||||||
first_name: 'Test Bot',
|
|
||||||
username: 'test_bot'
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock TelegramBot constructor
|
|
||||||
TelegramBot.mockImplementation(() => mockBotInstance);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore original ENV
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Initialization', () => {
|
|
||||||
it('sollte erfolgreich initialisieren wenn TELEGRAM_ENABLED=true', () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
expect(service.isAvailable()).toBe(true);
|
|
||||||
expect(TelegramBot).toHaveBeenCalledWith('test-bot-token-123', { polling: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte nicht initialisieren wenn TELEGRAM_ENABLED=false', () => {
|
|
||||||
process.env.TELEGRAM_ENABLED = 'false';
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
expect(service.isAvailable()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte fehlschlagen wenn TELEGRAM_BOT_TOKEN fehlt', () => {
|
|
||||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
expect(service.isAvailable()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte fehlschlagen wenn TELEGRAM_CHAT_ID fehlt', () => {
|
|
||||||
delete process.env.TELEGRAM_CHAT_ID;
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
expect(service.isAvailable()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sendTestMessage', () => {
|
|
||||||
it('sollte Test-Nachricht erfolgreich senden', async () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const result = await service.sendTestMessage();
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.message_id).toBe(42);
|
|
||||||
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
|
||||||
'-1001234567890',
|
|
||||||
expect.stringContaining('Telegram Service Test')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte null zurückgeben wenn Service nicht verfügbar', async () => {
|
|
||||||
process.env.TELEGRAM_ENABLED = 'false';
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const result = await service.sendTestMessage();
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte Fehler werfen bei Telegram-API-Fehler', async () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('API Error'));
|
|
||||||
|
|
||||||
await expect(service.sendTestMessage()).rejects.toThrow('API Error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatSocialMediaIcons', () => {
|
|
||||||
it('sollte Social Media Plattformen korrekt formatieren', () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const result = service.formatSocialMediaIcons(['facebook', 'instagram', 'tiktok']);
|
|
||||||
|
|
||||||
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte leeren String bei leerer Liste zurückgeben', () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const result = service.formatSocialMediaIcons([]);
|
|
||||||
|
|
||||||
expect(result).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte case-insensitive sein', () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const result = service.formatSocialMediaIcons(['FACEBOOK', 'Instagram', 'TikTok']);
|
|
||||||
|
|
||||||
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAdminUrl', () => {
|
|
||||||
it('sollte Admin-URL mit PUBLIC_URL generieren', () => {
|
|
||||||
process.env.PUBLIC_URL = 'https://test.example.com';
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const url = service.getAdminUrl();
|
|
||||||
|
|
||||||
expect(url).toBe('https://test.example.com/moderation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte Default-URL verwenden wenn PUBLIC_URL nicht gesetzt', () => {
|
|
||||||
delete process.env.PUBLIC_URL;
|
|
||||||
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const url = service.getAdminUrl();
|
|
||||||
|
|
||||||
expect(url).toBe('https://internal.hobbyhimmel.de/moderation');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sendUploadNotification (Phase 3)', () => {
|
|
||||||
it('sollte Upload-Benachrichtigung mit korrekten Daten senden', async () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
const groupData = {
|
|
||||||
name: 'Max Mustermann',
|
|
||||||
year: 2024,
|
|
||||||
title: 'Schweißkurs November',
|
|
||||||
imageCount: 12,
|
|
||||||
workshopConsent: true,
|
|
||||||
socialMediaConsents: ['instagram', 'tiktok'],
|
|
||||||
token: 'test-token-123'
|
|
||||||
};
|
|
||||||
|
|
||||||
await service.sendUploadNotification(groupData);
|
|
||||||
|
|
||||||
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
|
||||||
'-1001234567890',
|
|
||||||
expect.stringContaining('📸 Neuer Upload!')
|
|
||||||
);
|
|
||||||
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
|
||||||
'-1001234567890',
|
|
||||||
expect.stringContaining('Max Mustermann')
|
|
||||||
);
|
|
||||||
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
|
||||||
'-1001234567890',
|
|
||||||
expect.stringContaining('Bilder: 12')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sollte null zurückgeben und nicht werfen bei Fehler', async () => {
|
|
||||||
const service = new TelegramNotificationService();
|
|
||||||
|
|
||||||
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('Network error'));
|
|
||||||
|
|
||||||
const groupData = {
|
|
||||||
name: 'Test User',
|
|
||||||
year: 2024,
|
|
||||||
title: 'Test',
|
|
||||||
imageCount: 5,
|
|
||||||
workshopConsent: false,
|
|
||||||
socialMediaConsents: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await service.sendUploadNotification(groupData);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,148 +1,81 @@
|
||||||
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 (Session based)', () => {
|
describe('Auth Middleware Unit Test', () => {
|
||||||
let req, res, next;
|
let req, res, next;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
req = { session: null };
|
req = { headers: {} };
|
||||||
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 when no session exists', () => {
|
test('should reject missing Authorization header', () => {
|
||||||
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',
|
||||||
reason: 'SESSION_REQUIRED'
|
message: 'Authorization header fehlt'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject when session user is missing', () => {
|
test('should reject invalid Bearer format', () => {
|
||||||
req.session = {};
|
req.headers.authorization = 'Invalid token';
|
||||||
|
|
||||||
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({ reason: 'SESSION_REQUIRED' })
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('Ungültiges Authorization Format')
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject non-admin roles', () => {
|
test('should reject wrong token', () => {
|
||||||
req.session = { user: { id: 1, role: 'viewer' } };
|
req.headers.authorization = 'Bearer wrong-token';
|
||||||
|
|
||||||
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({ reason: 'SESSION_REQUIRED' })
|
expect.objectContaining({
|
||||||
|
message: 'Ungültiger Admin-Token'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should pass through for admin sessions and expose user on locals', () => {
|
test('should allow valid token', () => {
|
||||||
const adminUser = { id: 1, role: 'admin', username: 'testadmin' };
|
req.headers.authorization = 'Bearer test-key-123';
|
||||||
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.locals.adminUser).toEqual(adminUser);
|
expect(res.json).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
test('should handle missing ADMIN_API_KEY', () => {
|
||||||
describe('AdminAuthService', () => {
|
delete process.env.ADMIN_API_KEY;
|
||||||
beforeEach(async () => {
|
req.headers.authorization = 'Bearer any-token';
|
||||||
await dbManager.run('DELETE FROM admin_users');
|
|
||||||
});
|
requireAdminAuth(req, res, next);
|
||||||
|
|
||||||
afterEach(async () => {
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
await dbManager.run('DELETE FROM admin_users');
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
});
|
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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
/**
|
|
||||||
* Unit Tests für hostGate Middleware
|
|
||||||
* Testet Host-basierte Zugriffskontrolle
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Setup ENV VOR dem Require
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
|
||||||
process.env.PUBLIC_HOST = 'public.example.com';
|
|
||||||
process.env.INTERNAL_HOST = 'internal.example.com';
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
|
|
||||||
let hostGate;
|
|
||||||
|
|
||||||
// Helper to create mock request with headers
|
|
||||||
const createMockRequest = (hostname, path = '/') => {
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
get: (headerName) => {
|
|
||||||
if (headerName.toLowerCase() === 'x-forwarded-host') {
|
|
||||||
return hostname;
|
|
||||||
}
|
|
||||||
if (headerName.toLowerCase() === 'host') {
|
|
||||||
return hostname;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Host Gate Middleware', () => {
|
|
||||||
let req, res, next;
|
|
||||||
let originalEnv;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// Sichere Original-Env
|
|
||||||
originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
// Lade Modul NACH ENV setup
|
|
||||||
hostGate = require('../../../src/middlewares/hostGate');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock response object
|
|
||||||
res = {
|
|
||||||
status: jest.fn().mockReturnThis(),
|
|
||||||
json: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock next function
|
|
||||||
next = jest.fn();
|
|
||||||
|
|
||||||
// Reset req for each test
|
|
||||||
req = null;
|
|
||||||
|
|
||||||
// Setup Environment
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
|
||||||
process.env.PUBLIC_HOST = 'public.example.com';
|
|
||||||
process.env.INTERNAL_HOST = 'internal.example.com';
|
|
||||||
process.env.NODE_ENV = 'development'; // NOT 'test' to enable restrictions
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
// Restore Original-Env
|
|
||||||
process.env = originalEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Host Detection', () => {
|
|
||||||
test('should detect public host from X-Forwarded-Host header', () => {
|
|
||||||
req = createMockRequest('public.example.com');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
|
||||||
expect(req.isInternalHost).toBe(false);
|
|
||||||
expect(req.requestSource).toBe('public');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should detect internal host from X-Forwarded-Host header', () => {
|
|
||||||
req = createMockRequest('internal.example.com');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(false);
|
|
||||||
expect(req.isInternalHost).toBe(true);
|
|
||||||
expect(req.requestSource).toBe('internal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should fallback to Host header if X-Forwarded-Host not present', () => {
|
|
||||||
req = createMockRequest('public.example.com');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle localhost as internal host', () => {
|
|
||||||
req = createMockRequest('localhost:3000');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.isInternalHost).toBe(true);
|
|
||||||
expect(req.isPublicHost).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should strip port from hostname', () => {
|
|
||||||
req = createMockRequest('public.example.com:8080');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Route Protection', () => {
|
|
||||||
test('should block admin routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/admin/deletion-log');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
|
||||||
error: 'Not available on public host',
|
|
||||||
message: 'This endpoint is only available on the internal network'
|
|
||||||
});
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should block groups routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/groups');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should block slideshow routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/slideshow');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should block migration routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/migration/start');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should block auth login on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/auth/login');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Allowed Routes', () => {
|
|
||||||
test('should allow upload route on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/upload');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow manage routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/manage/abc-123');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow preview routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/previews/image.jpg');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow consent routes on public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/consent');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow all routes on internal host', () => {
|
|
||||||
req = createMockRequest('internal.example.com', '/api/admin/deletion-log');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Feature Flags', () => {
|
|
||||||
test('should bypass restriction when NODE_ENV is test and not explicitly enabled', () => {
|
|
||||||
// Reload module with test environment
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'false'; // Explicitly disabled
|
|
||||||
const hostGateTest = require('../../../src/middlewares/hostGate');
|
|
||||||
|
|
||||||
req = createMockRequest('public.example.com', '/api/admin/test');
|
|
||||||
hostGateTest(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
expect(req.isInternalHost).toBe(true);
|
|
||||||
|
|
||||||
// Restore
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should work in test environment when explicitly enabled', () => {
|
|
||||||
// Reload module with test environment BUT explicitly enabled
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true'; // Explicitly enabled
|
|
||||||
const hostGateTest = require('../../../src/middlewares/hostGate');
|
|
||||||
|
|
||||||
req = createMockRequest('public.example.com', '/api/admin/test');
|
|
||||||
hostGateTest(req, res, next);
|
|
||||||
|
|
||||||
// Should block because explicitly enabled
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Restore
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Request Source Tracking', () => {
|
|
||||||
test('should set requestSource to "public" for public host', () => {
|
|
||||||
req = createMockRequest('public.example.com', '/api/upload');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.requestSource).toBe('public');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should set requestSource to "internal" for internal host', () => {
|
|
||||||
req = createMockRequest('internal.example.com', '/api/admin/test');
|
|
||||||
hostGate(req, res, next);
|
|
||||||
|
|
||||||
expect(req.requestSource).toBe('internal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should set requestSource to "internal" when restrictions disabled', () => {
|
|
||||||
// Reload module with disabled restriction
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'false';
|
|
||||||
const hostGateDisabled = require('../../../src/middlewares/hostGate');
|
|
||||||
|
|
||||||
req = createMockRequest('anything.example.com', '/api/test');
|
|
||||||
hostGateDisabled(req, res, next);
|
|
||||||
|
|
||||||
expect(req.requestSource).toBe('internal');
|
|
||||||
|
|
||||||
// Restore
|
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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
|
|
||||||
};
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 159 B |
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 ""
|
||||||
|
|
@ -10,22 +10,6 @@ NODE_ENV=development
|
||||||
# Port for the backend server
|
# Port for the backend server
|
||||||
PORT=5000
|
PORT=5000
|
||||||
|
|
||||||
# Admin Session Secret (IMPORTANT: Change in production!)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
ADMIN_SESSION_SECRET=change-me-in-production
|
|
||||||
|
|
||||||
# Telegram Bot Configuration (optional)
|
|
||||||
TELEGRAM_ENABLED=false
|
|
||||||
# Send test message on server start (development only)
|
|
||||||
TELEGRAM_SEND_TEST_ON_START=false
|
|
||||||
# Bot-Token from @BotFather
|
|
||||||
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
|
||||||
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
|
||||||
# Chat-ID of the Telegram group (negative for groups!)
|
|
||||||
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
||||||
# Example: -1001234567890
|
|
||||||
TELEGRAM_CHAT_ID=your-chat-id-here
|
|
||||||
|
|
||||||
# Database settings (if needed in future)
|
# Database settings (if needed in future)
|
||||||
# DB_HOST=localhost
|
# DB_HOST=localhost
|
||||||
# DB_PORT=3306
|
# DB_PORT=3306
|
||||||
|
|
@ -6,4 +6,7 @@
|
||||||
# Production: http://backend:5000 (container-to-container)
|
# Production: http://backend:5000 (container-to-container)
|
||||||
API_URL=http://backend:5000
|
API_URL=http://backend:5000
|
||||||
|
|
||||||
# Public/Internal host separation (optional)
|
# Client URL - the URL where users access the frontend
|
||||||
|
# Development: http://localhost:3000 (dev server)
|
||||||
|
# Production: http://localhost (nginx on port 80)
|
||||||
|
CLIENT_URL=http://localhost
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Docker Compose Environment Variables for Development
|
|
||||||
# Copy this file to .env and adjust values
|
|
||||||
|
|
||||||
# Admin Session Secret (optional, has default: dev-session-secret-change-me)
|
|
||||||
#ADMIN_SESSION_SECRET=your-secret-here
|
|
||||||
|
|
||||||
# Telegram Bot Configuration (optional)
|
|
||||||
TELEGRAM_ENABLED=false
|
|
||||||
TELEGRAM_SEND_TEST_ON_START=false
|
|
||||||
# Bot-Token from @BotFather
|
|
||||||
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
|
||||||
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
|
||||||
# Chat-ID of the Telegram group (negative for groups!)
|
|
||||||
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
||||||
# Example: -1001234567890
|
|
||||||
TELEGRAM_CHAT_ID=your-chat-id-here
|
|
||||||
|
|
@ -12,8 +12,8 @@ RUN npm install
|
||||||
# Copy backend source code
|
# Copy backend source code
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
# Note: Environment variables are set via docker-compose.yml
|
# Copy development environment configuration
|
||||||
# No .env file needed in the image
|
COPY docker/dev/backend/config/.env ./.env
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ services:
|
||||||
- dev_frontend_node_modules:/app/node_modules
|
- dev_frontend_node_modules:/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
- API_URL=http://localhost:5001
|
- API_URL=http://backend-dev:5000
|
||||||
- PUBLIC_HOST=public.test.local
|
- CLIENT_URL=http://localhost:3000
|
||||||
- INTERNAL_HOST=internal.test.local
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-dev
|
- backend-dev
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -39,34 +38,10 @@ services:
|
||||||
- dev_backend_node_modules:/usr/src/app/node_modules
|
- dev_backend_node_modules:/usr/src/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=5000
|
|
||||||
- REMOVE_IMAGES=false
|
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-dev-session-secret-change-me}
|
|
||||||
- PUBLIC_HOST=public.test.local
|
|
||||||
- INTERNAL_HOST=internal.test.local
|
|
||||||
- ENABLE_HOST_RESTRICTION=true
|
|
||||||
- TRUST_PROXY_HOPS=0
|
|
||||||
- PUBLIC_UPLOAD_RATE_LIMIT=20
|
|
||||||
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
|
||||||
- TELEGRAM_SEND_TEST_ON_START=${TELEGRAM_SEND_TEST_ON_START:-false}
|
|
||||||
networks:
|
networks:
|
||||||
- dev-internal
|
- dev-internal
|
||||||
command: [ "npm", "run", "server" ]
|
command: [ "npm", "run", "server" ]
|
||||||
|
|
||||||
sqliteweb:
|
|
||||||
image: tomdesinto/sqliteweb
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ../../backend/src/data:/usr/src/app/src/data:ro # identischer Host-Pfad wie im Backend
|
|
||||||
command: /usr/src/app/src/data/db/image_uploader.db
|
|
||||||
networks:
|
|
||||||
- dev-internal
|
|
||||||
depends_on:
|
|
||||||
- backend-dev
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dev-internal:
|
dev-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ WORKDIR /app
|
||||||
# Copy package files first to leverage Docker cache for npm install
|
# Copy package files first to leverage Docker cache for npm install
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
# Copy environment shell script (generates env-config.js from ENV at runtime)
|
# Copy environment configuration
|
||||||
COPY docker/dev/frontend/config/env.sh ./env.sh
|
COPY docker/dev/frontend/config/env.sh ./env.sh
|
||||||
# Note: ENV variables are set via docker-compose.yml, not from .env file
|
COPY docker/dev/frontend/config/.env ./.env
|
||||||
|
|
||||||
# Make env.sh executable
|
# Make env.sh executable
|
||||||
RUN chmod +x ./env.sh
|
RUN chmod +x ./env.sh
|
||||||
|
|
@ -23,6 +23,9 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,23 @@ touch ./env-config.js
|
||||||
# Add assignment
|
# Add assignment
|
||||||
echo "window._env_ = {" >> ./env-config.js
|
echo "window._env_ = {" >> ./env-config.js
|
||||||
|
|
||||||
# List of environment variables to export (add more as needed)
|
# Read each line in .env file
|
||||||
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
|
# Each line represents key=value pairs
|
||||||
|
while read -r line || [[ -n "$line" ]];
|
||||||
# Read each environment variable and add to config
|
do
|
||||||
for varname in $ENV_VARS; do
|
# Split env variables by character `=`
|
||||||
# Get value from environment using indirect expansion
|
if printf '%s\n' "$line" | grep -q -e '='; then
|
||||||
value="${!varname}"
|
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
||||||
|
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
||||||
# Only add if value exists
|
|
||||||
if [ -n "$value" ]; then
|
|
||||||
echo " $varname: \"$value\"," >> ./env-config.js
|
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
# Read value of current variable if exists as Environment variable
|
||||||
|
value=$(printf '%s\n' "${!varname}")
|
||||||
|
# Otherwise use value from .env file
|
||||||
|
[[ -z $value ]] && value=${varvalue}
|
||||||
|
|
||||||
|
# Append configuration property to JS file
|
||||||
|
echo " $varname: \"$value\"," >> ./env-config.js
|
||||||
|
done < .env
|
||||||
|
|
||||||
echo "}" >> ./env-config.js
|
echo "}" >> ./env-config.js
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,11 @@ server {
|
||||||
# Frontend Routes (React Dev Server)
|
# Frontend Routes (React Dev Server)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|
||||||
# Moderation route proxy (session-protected in app layer)
|
# Protected route - Moderation (HTTP Basic Auth)
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# Docker Compose Environment Variables for Production
|
|
||||||
# Copy this file to .env and adjust values
|
|
||||||
# IMPORTANT: Keep this file secure and never commit .env to git!
|
|
||||||
|
|
||||||
# Admin Session Secret (REQUIRED: Generate new secret!)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
ADMIN_SESSION_SECRET=CHANGE-ME-IN-PRODUCTION
|
|
||||||
|
|
||||||
# Telegram Bot Configuration (optional)
|
|
||||||
# Set to true to enable Telegram notifications in production
|
|
||||||
TELEGRAM_ENABLED=false
|
|
||||||
# Bot-Token from @BotFather (production bot)
|
|
||||||
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
|
||||||
TELEGRAM_BOT_TOKEN=your-production-bot-token-here
|
|
||||||
# Chat-ID of the production Telegram group (negative for groups!)
|
|
||||||
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
||||||
# Example: -1001234567890
|
|
||||||
TELEGRAM_CHAT_ID=your-production-chat-id-here
|
|
||||||
|
|
@ -15,7 +15,7 @@ RUN npm install --production
|
||||||
COPY backend/src ./src
|
COPY backend/src ./src
|
||||||
|
|
||||||
# Copy production environment configuration
|
# Copy production environment configuration
|
||||||
# COPY docker/prod/backend/config/.env ./.env
|
COPY docker/prod/backend/config/.env ./.env
|
||||||
|
|
||||||
# Create data directories for file storage
|
# Create data directories for file storage
|
||||||
RUN mkdir -p src/data/images src/data/previews src/data/groups
|
RUN mkdir -p src/data/images src/data/previews src/data/groups
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ services:
|
||||||
- backend
|
- backend
|
||||||
environment:
|
environment:
|
||||||
- API_URL=http://backend:5000
|
- API_URL=http://backend:5000
|
||||||
- PUBLIC_HOST=public.test.local
|
- CLIENT_URL=http://localhost
|
||||||
- INTERNAL_HOST=internal.test.local
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- npm-nw
|
- npm-nw
|
||||||
- prod-internal
|
- prod-internal
|
||||||
|
|
@ -35,27 +33,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- prod-internal
|
- prod-internal
|
||||||
environment:
|
environment:
|
||||||
- REMOVE_IMAGES=false
|
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
|
||||||
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
|
|
||||||
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
|
|
||||||
- ADMIN_SESSION_COOKIE_SECURE=true
|
|
||||||
# Host Configuration (Public/Internal Separation)
|
|
||||||
- PUBLIC_HOST=public.test.local
|
|
||||||
- INTERNAL_HOST=internal.test.local
|
|
||||||
- ENABLE_HOST_RESTRICTION=true
|
|
||||||
- PUBLIC_UPLOAD_RATE_LIMIT=20
|
|
||||||
- PUBLIC_UPLOAD_RATE_WINDOW=3600000
|
|
||||||
# Trust nginx-proxy-manager (1 hop)
|
|
||||||
- TRUST_PROXY_HOPS=1
|
|
||||||
# Telegram Bot Configuration (optional)
|
|
||||||
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
|
||||||
- TELEGRAM_SEND_TEST_ON_START=false
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
npm-nw:
|
npm-nw:
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,19 @@ 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
|
||||||
|
|
||||||
# Default port exposure
|
# Default port exposure
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Copy .env shell script to container (generates env-config.js from ENV at runtime)
|
# Copy .env file and shell script to container
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
COPY docker/prod/frontend/config/env.sh ./env.sh
|
COPY docker/prod/frontend/config/env.sh ./env.sh
|
||||||
# Note: ENV variables are set via docker-compose.yml, not from .env file
|
COPY docker/prod/frontend/config/.env ./.env
|
||||||
|
|
||||||
# Add bash
|
# Add bash
|
||||||
RUN apk add --no-cache bash
|
RUN apk add --no-cache bash
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,23 @@ touch ./env-config.js
|
||||||
# Add assignment
|
# Add assignment
|
||||||
echo "window._env_ = {" >> ./env-config.js
|
echo "window._env_ = {" >> ./env-config.js
|
||||||
|
|
||||||
# List of environment variables to export (add more as needed)
|
# Read each line in .env file
|
||||||
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
|
# Each line represents key=value pairs
|
||||||
|
while read -r line || [[ -n "$line" ]];
|
||||||
# Read each environment variable and add to config
|
do
|
||||||
for varname in $ENV_VARS; do
|
# Split env variables by character `=`
|
||||||
# Get value from environment using indirect expansion
|
if printf '%s\n' "$line" | grep -q -e '='; then
|
||||||
value="${!varname}"
|
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
||||||
|
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
||||||
# Only add if value exists
|
|
||||||
if [ -n "$value" ]; then
|
|
||||||
echo " $varname: \"$value\"," >> ./env-config.js
|
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
# Read value of current variable if exists as Environment variable
|
||||||
|
value=$(printf '%s\n' "${!varname}")
|
||||||
|
# Otherwise use value from .env file
|
||||||
|
[[ -z $value ]] && value=${varvalue}
|
||||||
|
|
||||||
|
# Append configuration property to JS file
|
||||||
|
echo " $varname: \"$value\"," >> ./env-config.js
|
||||||
|
done < .env
|
||||||
|
|
||||||
echo "}" >> ./env-config.js
|
echo "}" >> ./env-config.js
|
||||||
|
|
|
||||||
|
|
@ -38,22 +38,90 @@ http {
|
||||||
# Allow large uploads (50MB)
|
# Allow large uploads (50MB)
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
# Generic API proxy for all backend endpoints under /api/
|
# API proxy to image-uploader-backend service
|
||||||
# This mirrors the dev setup: forward everything under /api/ to the backend service
|
location /upload {
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://image-uploader-backend:5000;
|
proxy_pass http://image-uploader-backend:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Allow large uploads for batch upload endpoints
|
# Allow large uploads for API too
|
||||||
|
client_max_body_size 50M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API routes for new multi-upload features
|
||||||
|
location /api/upload {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/upload;
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Allow large uploads for batch upload
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin auth/session endpoints (login/logout/setup/csrf)
|
# API - Download original images
|
||||||
location /auth/ {
|
location /api/download {
|
||||||
proxy_pass http://image-uploader-backend:5000;
|
proxy_pass http://image-uploader-backend:5000/download;
|
||||||
|
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 - Preview/thumbnail images (optimized for gallery views)
|
||||||
|
location /api/previews {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/previews;
|
||||||
|
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 (NO PASSWORD PROTECTION)
|
||||||
|
location /api/groups {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/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 - Social Media Consent Management (NO PASSWORD PROTECTION)
|
||||||
|
location /api/social-media {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/api/social-media;
|
||||||
|
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 - Management Portal (NO PASSWORD PROTECTION - Token-based auth)
|
||||||
|
location /api/manage {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/api/manage;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin API routes (NO password protection - protected by /moderation page access)
|
||||||
|
location /api/admin {
|
||||||
|
proxy_pass http://image-uploader-backend:5000/api/admin;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Protected API - Moderation API routes (password protected) - must come before /groups
|
||||||
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
@ -88,8 +156,11 @@ http {
|
||||||
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
|
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Moderation UI (session-protected within the app)
|
# Protected routes - Moderation (password protected)
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -10,14 +10,14 @@
|
||||||
## 🎯 Funktionale Anforderungen
|
## 🎯 Funktionale Anforderungen
|
||||||
|
|
||||||
### Must-Have
|
### Must-Have
|
||||||
- [x] **Automatische Löschung**: Gruppen mit `approved = false` werden nach 7 Tagen ab Upload-Zeitpunkt gelöscht
|
- [ ] **Automatische Löschung**: Gruppen mit `approved = false` werden nach 7 Tagen ab Upload-Zeitpunkt gelöscht
|
||||||
- [x] **Vollständige Löschung**: Datenbank-Einträge, Originalbilder und Preview-Bilder werden entfernt
|
- [ ] **Vollständige Löschung**: Datenbank-Einträge, Originalbilder und Preview-Bilder werden entfernt
|
||||||
- [x] **Cron-Job**: Tägliche Ausführung um 10:00 Uhr morgens
|
- [ ] **Cron-Job**: Tägliche Ausführung um 10:00 Uhr morgens
|
||||||
- [x] **Deletion Log**: Protokollierung gelöschter Gruppen in eigener Datenbanktabelle
|
- [ ] **Deletion Log**: Protokollierung gelöschter Gruppen in eigener Datenbanktabelle
|
||||||
- [x] **Anonymisierung**: Keine personenbezogenen Daten (Titel, Name, Beschreibung) im Log
|
- [ ] **Anonymisierung**: Keine personenbezogenen Daten (Titel, Name, Beschreibung) im Log
|
||||||
- [x] **Countdown-Anzeige**: In ModerationPage wird Restzeit bis zur Löschung angezeigt
|
- [ ] **Countdown-Anzeige**: In ModerationPage wird Restzeit bis zur Löschung angezeigt
|
||||||
- [x] **Admin-Übersicht**: Geschützter Bereich in ModerationPage für Lösch-Historie
|
- [ ] **Admin-Übersicht**: Geschützter Bereich in ModerationPage für Lösch-Historie
|
||||||
- [x] **Freigabe-Schutz**: Freigegebene Gruppen (`approved = true`) werden niemals automatisch gelöscht
|
- [ ] **Freigabe-Schutz**: Freigegebene Gruppen (`approved = true`) werden niemals automatisch gelöscht
|
||||||
|
|
||||||
### Nice-to-Have
|
### Nice-to-Have
|
||||||
- [ ] **Manuelle Verzögerung**: Admin kann Löschfrist verlängern (z.B. um weitere 7 Tage)
|
- [ ] **Manuelle Verzögerung**: Admin kann Löschfrist verlängern (z.B. um weitere 7 Tage)
|
||||||
|
|
@ -506,94 +506,94 @@ export const uploadImageBatch = async (files, metadata, descriptions = []) => {
|
||||||
|
|
||||||
### Manuelle Tests
|
### Manuelle Tests
|
||||||
|
|
||||||
- [x] Upload mehrerer Bilder mit verschiedenen Beschreibungen
|
- [ ] Upload mehrerer Bilder mit verschiedenen Beschreibungen
|
||||||
- [x] Upload ohne Beschreibungen
|
- [ ] Upload ohne Beschreibungen
|
||||||
- [x] Bearbeiten bestehender Gruppen
|
- [ ] Bearbeiten bestehender Gruppen
|
||||||
- [x] Slideshow mit Beschreibungen testen
|
- [ ] Slideshow mit Beschreibungen testen
|
||||||
- [ ] Mobile-Ansicht testen
|
- [ ] Mobile-Ansicht testen
|
||||||
- [x] Performance mit vielen Bildern testen
|
- [ ] Performance mit vielen Bildern testen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Implementation TODO
|
## 📝 Implementation TODO
|
||||||
|
|
||||||
### Phase 1: Backend Foundation ✅
|
### Phase 1: Backend Foundation ✅
|
||||||
- [x] **Task 1.1:** Datenbank-Migration erstellen
|
- [ ] **Task 1.1:** Datenbank-Migration erstellen
|
||||||
- [x] `004_add_image_description.sql` erstellen
|
- [ ] `004_add_image_description.sql` erstellen
|
||||||
- [x] Migration in `DatabaseManager.js` registrieren
|
- [ ] Migration in `DatabaseManager.js` registrieren
|
||||||
- [ x Lokale DB testen
|
- [ ] Lokale DB testen
|
||||||
|
|
||||||
- [x] **Task 1.2:** Repository-Layer erweitern
|
- [ ] **Task 1.2:** Repository-Layer erweitern
|
||||||
- [x] `updateImageDescription()` in `GroupRepository.js`
|
- [ ] `updateImageDescription()` in `GroupRepository.js`
|
||||||
- [x] `updateBatchImageDescriptions()` in `GroupRepository.js`
|
- [ ] `updateBatchImageDescriptions()` in `GroupRepository.js`
|
||||||
- [x] `getImagesByGroupId()` erweitern für `image_description`
|
- [ ] `getImagesByGroupId()` erweitern für `image_description`
|
||||||
|
|
||||||
- [x] **Task 1.3:** API-Routes implementieren
|
- [ ] **Task 1.3:** API-Routes implementieren
|
||||||
- [x] `PATCH /groups/:groupId/images/:imageId` in `routes/groups.js`
|
- [ ] `PATCH /groups/:groupId/images/:imageId` in `routes/groups.js`
|
||||||
- [x] `PATCH /groups/:groupId/images/batch-description` in `routes/groups.js`
|
- [ ] `PATCH /groups/:groupId/images/batch-description` in `routes/groups.js`
|
||||||
- [x] Validierung hinzufügen (max 200 Zeichen)
|
- [ ] Validierung hinzufügen (max 200 Zeichen)
|
||||||
- [x] GET Routes erweitern (image_description returnen)
|
- [ ] GET Routes erweitern (image_description returnen)
|
||||||
|
|
||||||
- [x] **Task 1.4:** Upload-Route erweitern
|
- [ ] **Task 1.4:** Upload-Route erweitern
|
||||||
- [x] `batchUpload.js` Route akzeptiert `descriptions` Parameter
|
- [ ] `batchUpload.js` Route akzeptiert `descriptions` Parameter
|
||||||
- [x] Speichere Beschreibungen beim Upload
|
- [ ] Speichere Beschreibungen beim Upload
|
||||||
- [x] Backward-Kompatibilität testen
|
- [ ] Backward-Kompatibilität testen
|
||||||
|
|
||||||
### Phase 2: Frontend Core Components ✅
|
### Phase 2: Frontend Core Components ✅
|
||||||
- [x] **Task 2.1:** ImageGalleryCard.js anpassen
|
- [ ] **Task 2.1:** ImageGalleryCard.js anpassen
|
||||||
- [x] "Sort" Button durch "Edit" Button ersetzen
|
- [ ] "Sort" Button durch "Edit" Button ersetzen
|
||||||
- [x] Edit-Modus UI implementieren (Textarea)
|
- [ ] Edit-Modus UI implementieren (Textarea)
|
||||||
- [x] Props hinzufügen: `isEditMode`, `onEditMode`, `imageDescription`, `onDescriptionChange`
|
- [ ] Props hinzufügen: `isEditMode`, `onEditMode`, `imageDescription`, `onDescriptionChange`
|
||||||
- [x] Zeichenzähler implementieren
|
- [ ] Zeichenzähler implementieren
|
||||||
- [x] Validierung (max 200 Zeichen)
|
- [ ] Validierung (max 200 Zeichen)
|
||||||
|
|
||||||
- [x] **Task 2.2:** ImageGallery.js erweitern
|
- [ ] **Task 2.2:** ImageGallery.js erweitern
|
||||||
- [x] Neue Props durchreichen
|
- [ ] Neue Props durchreichen
|
||||||
- [x] Edit-Modus State-Management
|
- [ ] Edit-Modus State-Management
|
||||||
|
|
||||||
- [x] **Task 2.3:** CSS-Styles hinzufügen
|
- [ ] **Task 2.3:** CSS-Styles hinzufügen
|
||||||
- [x] `ImageGallery.css` erweitern
|
- [ ] `ImageGallery.css` erweitern
|
||||||
- [x] Textarea-Styles
|
- [ ] Textarea-Styles
|
||||||
- [x] Zeichenzähler-Styles
|
- [ ] Zeichenzähler-Styles
|
||||||
- [x] Edit-Button-Styles
|
- [ ] Edit-Button-Styles
|
||||||
- [x] Mobile-Optimierung
|
- [ ] Mobile-Optimierung
|
||||||
|
|
||||||
### Phase 3: Upload Flow Integration ✅
|
### Phase 3: Upload Flow Integration ✅
|
||||||
- [x] **Task 3.1:** MultiUploadPage.js erweitern
|
- [ ] **Task 3.1:** MultiUploadPage.js erweitern
|
||||||
- [x] State für Edit-Modus hinzufügen
|
- [ ] State für Edit-Modus hinzufügen
|
||||||
- [x] State für Beschreibungen hinzufügen
|
- [ ] State für Beschreibungen hinzufügen
|
||||||
- [x] Handler für Edit-Modus implementieren
|
- [ ] Handler für Edit-Modus implementieren
|
||||||
- [x] Handler für Beschreibungsänderungen implementieren
|
- [ ] Handler für Beschreibungsänderungen implementieren
|
||||||
- [x] Upload-Logik erweitern (Beschreibungen mitschicken)
|
- [ ] Upload-Logik erweitern (Beschreibungen mitschicken)
|
||||||
- [x] Edit-Mode Toggle UI hinzufügen
|
- [ ] Edit-Mode Toggle UI hinzufügen
|
||||||
|
|
||||||
- [x] **Task 3.2:** batchUpload.js erweitern
|
- [ ] **Task 3.2:** batchUpload.js erweitern
|
||||||
- [x] Funktionssignatur anpassen (descriptions Parameter)
|
- [ ] Funktionssignatur anpassen (descriptions Parameter)
|
||||||
- [x] FormData um Beschreibungen erweitern
|
- [ ] FormData um Beschreibungen erweitern
|
||||||
- [x] Error-Handling
|
- [ ] Error-Handling
|
||||||
|
|
||||||
### Phase 4: Moderation Integration ✅
|
### Phase 4: Moderation Integration ✅
|
||||||
- [x] **Task 4.1:** ModerationGroupImagesPage.js erweitern
|
- [ ] **Task 4.1:** ModerationGroupImagesPage.js erweitern
|
||||||
- [x] State für Edit-Modus hinzufügen
|
- [ ] State für Edit-Modus hinzufügen
|
||||||
- [x] State für Beschreibungen hinzufügen
|
- [ ] State für Beschreibungen hinzufügen
|
||||||
- [x] `loadGroup()` erweitern (Beschreibungen laden)
|
- [ ] `loadGroup()` erweitern (Beschreibungen laden)
|
||||||
- [x] Handler für Beschreibungsänderungen implementieren
|
- [ ] Handler für Beschreibungsänderungen implementieren
|
||||||
- [x] `handleSaveDescriptions()` implementieren
|
- [ ] `handleSaveDescriptions()` implementieren
|
||||||
- [x] Edit-Mode Toggle UI hinzufügen
|
- [ ] Edit-Mode Toggle UI hinzufügen
|
||||||
- [x] Optimistic Updates
|
- [ ] Optimistic Updates
|
||||||
|
|
||||||
### Phase 5: Slideshow Integration ✅
|
### Phase 5: Slideshow Integration ✅
|
||||||
- [x] **Task 5.1:** SlideshowPage.js erweitern
|
- [ ] **Task 5.1:** SlideshowPage.js erweitern
|
||||||
- [x] Beschreibungs-Anzeige UI implementieren
|
- [ ] Beschreibungs-Anzeige UI implementieren
|
||||||
- [x] CSS für Slideshow-Beschreibung
|
- [ ] CSS für Slideshow-Beschreibung
|
||||||
- [x] Responsive Design
|
- [ ] Responsive Design
|
||||||
- [x] Conditional Rendering (nur wenn Beschreibung vorhanden)
|
- [ ] Conditional Rendering (nur wenn Beschreibung vorhanden)
|
||||||
|
|
||||||
- [x] **Task 5.2:** Slideshow-Styles
|
- [ ] **Task 5.2:** Slideshow-Styles
|
||||||
- [x] `.slideshow-description` CSS
|
- [ ] `.slideshow-description` CSS
|
||||||
- [x] Overlay-Styling
|
- [ ] Overlay-Styling
|
||||||
- [x] Animation (optional)
|
- [ ] Animation (optional)
|
||||||
- [x] Mobile-Ansicht
|
- [ ] Mobile-Ansicht
|
||||||
|
|
||||||
### Phase 6: Groups Overview Integration ✅
|
### Phase 6: Groups Overview Integration ✅
|
||||||
- [ ] **Task 6.1:** GroupsOverviewPage.js erweitern
|
- [ ] **Task 6.1:** GroupsOverviewPage.js erweitern
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# Development Environment Variables
|
|
||||||
# Allow access from custom hostnames (public.test.local, internal.test.local)
|
|
||||||
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
|
||||||
|
|
||||||
# Use 0.0.0.0 to allow external access
|
|
||||||
HOST=0.0.0.0
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
# Frontend Environment Variables
|
# Frontend Environment Variables
|
||||||
|
|
||||||
# Currently no frontend-specific secrets are required. Add overrides (e.g. public API URLs)
|
# Admin API Authentication Token
|
||||||
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
|
# Generate with: openssl rand -hex 32
|
||||||
# Example:
|
# Must match ADMIN_API_KEY in backend/.env
|
||||||
# REACT_APP_PUBLIC_API_BASE=https://example.com
|
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
|
||||||
|
|
||||||
# Host Configuration (for public/internal separation)
|
# API Base URL (optional, defaults to same domain)
|
||||||
PUBLIC_HOST=deinprojekt.hobbyhimmel.de
|
# REACT_APP_API_URL=http://localhost:3001
|
||||||
INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
|
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# Error Handling System
|
|
||||||
|
|
||||||
Das Frontend verfügt jetzt über ein vollständiges Error Handling System für HTTP-Fehler und React-Fehler.
|
|
||||||
|
|
||||||
## ✅ Migration abgeschlossen
|
|
||||||
|
|
||||||
Alle kritischen API-Aufrufe wurden auf das neue Error-Handling-System migriert:
|
|
||||||
- ✅ `sendRequest.js` → `apiClient` (axios-basiert)
|
|
||||||
- ✅ `batchUpload.js` → `apiFetch`
|
|
||||||
- ✅ `PublicGroupImagesPage.js` → `apiFetch`
|
|
||||||
- ✅ `ManagementPortalPage.js` → `apiFetch`
|
|
||||||
- ✅ `DeleteGroupButton.js` → `apiFetch`
|
|
||||||
- ✅ `ConsentManager.js` → `apiFetch`
|
|
||||||
- ✅ `ImageDescriptionManager.js` → `apiFetch`
|
|
||||||
- ✅ `GroupMetadataEditor.js` → `apiFetch`
|
|
||||||
|
|
||||||
**Hinweis:** `adminApi.js` und `socialMediaApi.js` verwenden ihr eigenes `adminFetch`-System mit CSRF-Token-Handling und wurden bewusst nicht migriert.
|
|
||||||
|
|
||||||
## Komponenten
|
|
||||||
|
|
||||||
### 1. ErrorBoundary (`/Components/ComponentUtils/ErrorBoundary.js`)
|
|
||||||
- Fängt React-Fehler (z.B. Rendering-Fehler) ab
|
|
||||||
- Zeigt automatisch die 500-Error-Page bei unerwarteten Fehlern
|
|
||||||
- Loggt Fehlerdetails in der Konsole für Debugging
|
|
||||||
|
|
||||||
### 2. API Client (`/Utils/apiClient.js`)
|
|
||||||
- Axios-Instance mit Response-Interceptor
|
|
||||||
- Für FormData-Uploads (z.B. Bilder)
|
|
||||||
- Automatische Weiterleitung zu Error-Pages basierend auf HTTP-Statuscode
|
|
||||||
|
|
||||||
### 3. API Fetch Wrapper (`/Utils/apiFetch.js`)
|
|
||||||
- Native Fetch-Wrapper mit Error-Handling
|
|
||||||
- Für Standard-JSON-API-Aufrufe
|
|
||||||
- Automatische Weiterleitung zu Error-Pages:
|
|
||||||
- **403 Forbidden** → `/error/403`
|
|
||||||
- **500 Internal Server Error** → `/error/500`
|
|
||||||
- **502 Bad Gateway** → `/error/502`
|
|
||||||
- **503 Service Unavailable** → `/error/503`
|
|
||||||
|
|
||||||
### 4. Error Pages Routes (`App.js`)
|
|
||||||
- Neue Routes für alle Error-Pages:
|
|
||||||
- `/error/403` - Forbidden
|
|
||||||
- `/error/500` - Internal Server Error
|
|
||||||
- `/error/502` - Bad Gateway
|
|
||||||
- `/error/503` - Service Unavailable
|
|
||||||
- `*` - 404 Not Found (catch-all)
|
|
||||||
|
|
||||||
## Verwendung
|
|
||||||
|
|
||||||
### Für File-Uploads (FormData)
|
|
||||||
Verwende `apiClient` für multipart/form-data Uploads:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import apiClient from '../Utils/apiClient';
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
apiClient.post('/upload', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
// Success handling
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
// Automatische Weiterleitung zu Error-Page bei 403, 500, 502, 503
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Für JSON-API-Aufrufe
|
|
||||||
Verwende `apiFetch` oder Helper-Funktionen:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { apiFetch, apiGet, apiPost } from '../Utils/apiFetch';
|
|
||||||
|
|
||||||
// GET Request
|
|
||||||
const data = await apiGet('/api/groups');
|
|
||||||
|
|
||||||
// POST Request
|
|
||||||
const result = await apiPost('/api/groups', { name: 'Test' });
|
|
||||||
|
|
||||||
// Custom Request
|
|
||||||
const response = await apiFetch('/api/groups/123', {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend Error Codes
|
|
||||||
|
|
||||||
Das Backend liefert bereits folgende Statuscodes:
|
|
||||||
|
|
||||||
- **403**: CSRF-Fehler, fehlende Admin-Session, public host auf internal routes
|
|
||||||
- **500**: Datenbank-Fehler, Upload-Fehler, Migration-Fehler
|
|
||||||
- **502**: Nicht implementiert (wird von Reverse Proxy geliefert)
|
|
||||||
- **503**: Nicht implementiert (für Wartungsmodus vorgesehen)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Um die Error-Pages zu testen:
|
|
||||||
|
|
||||||
1. **403**: Versuche ohne Login auf Admin-Routen zuzugreifen
|
|
||||||
2. **404**: Navigiere zu einer nicht existierenden Route (z.B. `/nicht-vorhanden`)
|
|
||||||
3. **500**: Simuliere Backend-Fehler
|
|
||||||
4. **502/503**: Manuell über `/error/502` oder `/error/503` aufrufen
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ App.js │
|
|
||||||
│ ┌───────────────────────────────────────┐ │
|
|
||||||
│ │ ErrorBoundary │ │
|
|
||||||
│ │ (fängt React-Fehler) │ │
|
|
||||||
│ │ ┌─────────────────────────────────┐ │ │
|
|
||||||
│ │ │ Router │ │ │
|
|
||||||
│ │ │ ┌───────────────────────────┐ │ │ │
|
|
||||||
│ │ │ │ Routes │ │ │ │
|
|
||||||
│ │ │ │ - / │ │ │ │
|
|
||||||
│ │ │ │ - /error/403 │ │ │ │
|
|
||||||
│ │ │ │ - /error/500 │ │ │ │
|
|
||||||
│ │ │ │ - /error/502 │ │ │ │
|
|
||||||
│ │ │ │ - /error/503 │ │ │ │
|
|
||||||
│ │ │ │ - * (404) │ │ │ │
|
|
||||||
│ │ │ └───────────────────────────┘ │ │ │
|
|
||||||
│ │ └─────────────────────────────────┘ │ │
|
|
||||||
│ └───────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ API Layer │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ apiClient.js (axios) │
|
|
||||||
│ - FormData/File-Uploads │
|
|
||||||
│ - Response Interceptor │
|
|
||||||
│ │
|
|
||||||
│ apiFetch.js (fetch) │
|
|
||||||
│ - JSON-API-Aufrufe │
|
|
||||||
│ - Error-Response-Handling │
|
|
||||||
│ │
|
|
||||||
│ adminApi.js (fetch + CSRF) │
|
|
||||||
│ - Admin-Authentifizierung │
|
|
||||||
│ - CSRF-Token-Management │
|
|
||||||
│ - Nicht migriert (eigenes System) │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Error-Flow:
|
|
||||||
HTTP 403/500/502/503 → Interceptor/Handler → window.location.href → Error-Page
|
|
||||||
React Error → ErrorBoundary → 500-Page
|
|
||||||
```
|
|
||||||
|
|
@ -103,73 +103,75 @@ 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. Admin-Session & CSRF einrichten
|
### 2. Environment Variable für Admin Token hinzufügen
|
||||||
|
|
||||||
Die Admin-API verwendet jetzt serverseitige Sessions mit CSRF-Schutz. Statt Tokens in `.env` zu hinterlegen, erfolgt die Authentifizierung über Login-Endpunkte:
|
```bash
|
||||||
|
# frontend/.env oder frontend/.env.local
|
||||||
|
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
|
||||||
|
```
|
||||||
|
|
||||||
1. **Setup-Status abfragen** – `GET /auth/setup/status` → `{ needsSetup, hasSession }`
|
**Token generieren:**
|
||||||
2. **Ersten Admin anlegen** – `POST /auth/setup/initial-admin` (nur einmal nötig)
|
```bash
|
||||||
3. **Login** – `POST /auth/login` mit `{ username, password }`
|
# Linux/Mac:
|
||||||
4. **CSRF Token holen** – `GET /auth/csrf-token` (liefert `csrfToken` und setzt HttpOnly Session-Cookie)
|
openssl rand -hex 32
|
||||||
|
|
||||||
Alle nachfolgenden Admin-Requests senden automatisch das Session-Cookie (`credentials: 'include'`) und den `X-CSRF-Token` Header.
|
# Node.js:
|
||||||
|
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 Session):
|
#### Vorher (ohne Auth):
|
||||||
```javascript
|
```javascript
|
||||||
const response = await fetch('/api/admin/groups');
|
const response = await fetch('/api/admin/groups');
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Nachher (mit Session + CSRF):
|
#### Nachher (mit Bearer Token):
|
||||||
```javascript
|
```javascript
|
||||||
const response = await fetch('/api/admin/groups', {
|
const response = await fetch('/api/admin/groups', {
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-Token': csrfToken, // nur bei mutierenden Requests zwingend nötig
|
'Authorization': `Bearer ${process.env.REACT_APP_ADMIN_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Zentrale API-Helper-Funktion erstellen
|
### 3. Zentrale API-Helper-Funktion erstellen
|
||||||
|
|
||||||
**Empfohlen**: Nutze `src/services/adminApi.js` als einzige Stelle, die Session- und CSRF-Handling kapselt:
|
**Empfohlen**: Erstelle eine zentrale Funktion für alle Admin-API-Calls:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
// src/services/adminApiService.js
|
||||||
let csrfToken = null;
|
const ADMIN_API_KEY = process.env.REACT_APP_ADMIN_API_KEY;
|
||||||
|
|
||||||
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 method = (options.method || 'GET').toUpperCase();
|
const defaultHeaders = {
|
||||||
const headers = new Headers(options.headers || {});
|
'Authorization': `Bearer ${ADMIN_API_KEY}`,
|
||||||
|
'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,
|
||||||
method,
|
headers: {
|
||||||
credentials: 'include',
|
...defaultHeaders,
|
||||||
headers
|
...options.headers
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.status === 403) {
|
||||||
throw await parseError(response);
|
throw new Error('Authentication failed - Invalid or missing admin token');
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -177,20 +179,22 @@ export const adminFetch = async (url, options = {}) => {
|
||||||
```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) {
|
console.error('Admin API error:', error);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -221,8 +225,7 @@ 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/admin/social-media/platforms` für Moderationsfilter
|
- ❌ `/api/social-media/platforms` → ✅ `/api/admin/social-media/platforms`
|
||||||
- ✅ `/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}`
|
||||||
|
|
@ -230,12 +233,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 (Session + CSRF erforderlich):
|
### Admin-Endpoints (benötigen Bearer Token):
|
||||||
- `Components/Pages/ModerationGroupsPage.js` - Alle Moderations-Calls
|
- `Components/Pages/ModerationGroupsPage.js` - Alle Admin-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 (Admin)
|
- `Components/ComponentUtils/ConsentManager.js` - Consent Export (wenn Admin)
|
||||||
- `services/reorderService.js` - Admin-Reorder (falls im Einsatz)
|
- `services/reorderService.js` - Admin-Reorder (wenn vorhanden)
|
||||||
|
|
||||||
### 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/...`)
|
||||||
|
|
@ -253,12 +256,13 @@ 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 (Session)
|
### Phase 2: Admin Authentication
|
||||||
- [ ] `AdminSessionProvider` wrappt die App
|
- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefügt
|
||||||
- [ ] `AdminSessionGate` schützt alle Moderationsseiten
|
- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert
|
||||||
- [ ] `adminApi.js` nutzt `credentials: 'include'` + `X-CSRF-Token`
|
- [ ] Zentrale `adminFetch` Funktion erstellt
|
||||||
- [ ] Login- und Initial-Setup-Formulare eingebunden
|
- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefügt
|
||||||
- [ ] Fehlerbehandlung für `401/403 (SESSION_REQUIRED/CSRF_INVALID)` ergänzt
|
- [ ] Authorization Header zu `/api/system/*` Calls hinzugefügt (falls vorhanden)
|
||||||
|
- [ ] 403 Error Handling implementiert
|
||||||
|
|
||||||
### Phase 3: Testing & Deployment
|
### Phase 3: Testing & Deployment
|
||||||
- [ ] Frontend lokal getestet (alle Routen)
|
- [ ] Frontend lokal getestet (alle Routen)
|
||||||
|
|
@ -272,20 +276,29 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
||||||
|
|
||||||
### Lokales Testing
|
### Lokales Testing
|
||||||
|
|
||||||
1. Backend starten (`npm run dev`) – stellt Session- & Auth-Routen bereit.
|
1. Backend mit Admin-Key starten:
|
||||||
2. Frontend starten (`npm start`).
|
```bash
|
||||||
3. `/moderation` öffnen:
|
cd backend
|
||||||
- **Falls kein Admin existiert** → Setup-Formular ausfüllen.
|
echo "ADMIN_API_KEY=test-key-12345" >> .env
|
||||||
- Danach mit frisch erstellten Credentials anmelden.
|
npm run dev
|
||||||
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
|
||||||
|
|
||||||
- ✅ Moderation funktioniert mit aktiver Session
|
- ✅ Admin-Funktionen funktionieren mit gültigem Token
|
||||||
- ✅ Login/Logout ändert sofort den Zugriff auf Seiten
|
- ✅ 403 Error bei fehlendem/falschem Token
|
||||||
- ✅ CSRF-geschützte Aktionen schlagen fehl, wenn Token manipuliert wird
|
- ✅ Consent-Export funktioniert
|
||||||
- ✅ Consent-Export & Reorder funktionieren weiterhin
|
- ✅ Gruppen löschen funktioniert
|
||||||
- ✅ Öffentliche Routen bleiben ohne Login erreichbar
|
- ✅ Bilder neu anordnen funktioniert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -295,46 +308,56 @@ 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: "Session Required" / 403 Fehler
|
### Problem: "403 Forbidden" Fehler
|
||||||
|
|
||||||
**Ursachen:**
|
**Ursachen:**
|
||||||
1. Session abgelaufen (Inaktivität, Browser geschlossen)
|
1. `REACT_APP_ADMIN_API_KEY` nicht gesetzt
|
||||||
2. Cookies blockiert (Third-Party/SameSite Einstellungen)
|
2. Token falsch konfiguriert (Frontend ≠ Backend)
|
||||||
|
3. Token enthält Leerzeichen/Zeilenumbrüche
|
||||||
|
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
- Seite neu laden → Login-Formular erscheint
|
```bash
|
||||||
- Browser-Einstellungen prüfen: Cookies für Host erlauben
|
# Frontend .env prüfen:
|
||||||
|
cat frontend/.env | grep ADMIN_API_KEY
|
||||||
|
|
||||||
### Problem: "CSRF invalid"
|
# Backend .env prüfen:
|
||||||
|
cat backend/.env | grep ADMIN_API_KEY
|
||||||
|
|
||||||
**Ursachen:**
|
# Beide müssen identisch sein!
|
||||||
- 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:**
|
||||||
- `AdminSessionGate` neu laden → holt automatisch neues Token
|
```bash
|
||||||
- Sicherstellen, dass `adminApi` bei mutierenden Calls `X-CSRF-Token` setzt
|
cd backend
|
||||||
|
echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
### Problem: Setup-Formular erscheint nicht
|
### Problem: Token wird nicht gesendet
|
||||||
|
|
||||||
**Ursachen:**
|
**Prüfen in Browser DevTools:**
|
||||||
- Bereits ein Admin vorhanden
|
1. Network Tab öffnen
|
||||||
|
2. Admin-API-Request auswählen
|
||||||
|
3. "Headers" Tab prüfen
|
||||||
|
4. Sollte enthalten: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
**Lösung:**
|
### Problem: CORS-Fehler
|
||||||
- Bestehende Admin-Credentials verwenden
|
|
||||||
- Falls vergessen: über Datenbank (Tabelle `admin_users`) neuen Admin eintragen oder Passwort zurücksetzen
|
|
||||||
|
|
||||||
### Problem: Login schlägt wiederholt fehl
|
**Ursache:** Backend CORS-Middleware blockiert Authorization-Header
|
||||||
|
|
||||||
**Checks:**
|
**Lösung:** Bereits implementiert in `backend/src/middlewares/cors.js`:
|
||||||
1. Backend-Logs prüfen (Rate-Limits? falsches Passwort?)
|
```javascript
|
||||||
2. Prüfen, ob `ADMIN_SESSION_SECRET` gesetzt ist (sonst keine stabilen Sessions)
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
3. Browser-Konsole → Network Request `POST /auth/login` analysieren
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -342,11 +365,13 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
||||||
|
|
||||||
### Production Checklist
|
### Production Checklist
|
||||||
|
|
||||||
- [ ] Sicheres `ADMIN_SESSION_SECRET` (>= 32 random bytes) gesetzt
|
- [ ] Sicheren Admin-Token generiert (min. 32 Bytes hex)
|
||||||
- [ ] HTTPS aktiviert (Cookies: `Secure`, `SameSite=Strict`)
|
- [ ] Token in Backend `.env` als `ADMIN_API_KEY`
|
||||||
- [ ] Session-DB Pfad (`ADMIN_SESSION_DIR`/`ADMIN_SESSION_DB`) persistent gemacht
|
- [ ] Token in Frontend Build-Environment als `REACT_APP_ADMIN_API_KEY`
|
||||||
- [ ] Admin-Benutzer erstellt und dokumentiert (kein Secret im Frontend)
|
- [ ] Token NICHT in Git committed (in `.gitignore`)
|
||||||
- [ ] Monitoring/Alerting für fehlgeschlagene Logins eingerichtet
|
- [ ] HTTPS verwendet (Token über unverschlüsselte HTTP ist unsicher)
|
||||||
|
- [ ] Token-Rotation-Prozess dokumentiert
|
||||||
|
- [ ] Backup des Tokens an sicherem Ort gespeichert
|
||||||
|
|
||||||
### Docker Deployment
|
### Docker Deployment
|
||||||
|
|
||||||
|
|
@ -355,17 +380,16 @@ grep -rn "/api/admin" --include="*.js" --include="*.jsx"
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
environment:
|
environment:
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
- ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||||
- ADMIN_SESSION_DIR=/data/sessions
|
|
||||||
# optional weitere Backend-ENV Variablen
|
|
||||||
frontend:
|
frontend:
|
||||||
environment:
|
environment:
|
||||||
- PUBLIC_URL=${PUBLIC_URL:-/}
|
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env (nicht in Git!)
|
# .env (nicht in Git!)
|
||||||
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
|
ADMIN_API_KEY=your-production-token-here
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.1",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.1",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.1",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -31,10 +31,9 @@
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject"
|
||||||
"version": "cd .. && ./scripts/sync-version.sh && git add -A"
|
|
||||||
},
|
},
|
||||||
"proxy": "http://backend-dev:5000",
|
"proxy": "http://localhost:5001",
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&display=swap" rel="stylesheet">
|
||||||
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
|
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
|
||||||
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
|
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,15 @@
|
||||||
/* Main shared styles for cards, buttons, modals used across pages */
|
/* Main shared styles for cards, buttons, modals used across pages */
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
TYPOGRAPHY - Zentrale Schrift-Definitionen
|
|
||||||
============================================ */
|
|
||||||
body {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
color: #333333;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, .h1 {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 28px;
|
|
||||||
color: #333333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2, .h2 {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #333333;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3, .h3 {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #333333;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p, .text-body {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #666666;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-subtitle {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-small {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center { text-align: center; }
|
|
||||||
.text-left { text-align: left; }
|
|
||||||
.text-right { text-align: right; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LAYOUT & CONTAINERS
|
|
||||||
============================================ */
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PAGE HEADERS
|
|
||||||
============================================ */
|
|
||||||
.page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 28px;
|
|
||||||
color: #333333;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
font-family: 'Open Sans', sans-serif;
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #666666;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
UTILITY CLASSES
|
|
||||||
============================================ */
|
|
||||||
.flex-center {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center-block {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spacing utilities */
|
|
||||||
.mt-1 { margin-top: 8px; }
|
|
||||||
.mt-2 { margin-top: 16px; }
|
|
||||||
.mt-3 { margin-top: 24px; }
|
|
||||||
.mt-4 { margin-top: 32px; }
|
|
||||||
.mb-1 { margin-bottom: 8px; }
|
|
||||||
.mb-2 { margin-bottom: 16px; }
|
|
||||||
.mb-3 { margin-bottom: 24px; }
|
|
||||||
.mb-4 { margin-bottom: 32px; }
|
|
||||||
.p-2 { padding: 16px; }
|
|
||||||
.p-3 { padding: 24px; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SUCCESS BOX (Upload Success)
|
|
||||||
============================================ */
|
|
||||||
.success-box {
|
|
||||||
margin-top: 32px;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 20px rgba(76, 175, 80, 0.4);
|
|
||||||
animation: slideIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-box h2 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-box p {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box-highlight {
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EXISTING STYLES BELOW
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Page-specific styles for GroupsOverviewPage */
|
/* Page-specific styles for GroupsOverviewPage */
|
||||||
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
|
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
|
||||||
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
|
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
|
||||||
.header-title { font-family: 'Open Sans', sans-serif; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
.header-title { font-family: roboto; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
||||||
.header-subtitle { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
.header-subtitle { font-family: roboto; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
||||||
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
||||||
|
|
||||||
/* Page-specific styles for ModerationPage */
|
/* Page-specific styles for ModerationPage */
|
||||||
.moderation-page { font-family: 'Open Sans', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; }
|
||||||
p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
|
|
||||||
.moderation-content h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
|
||||||
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
||||||
.moderation-error { color:#dc3545; }
|
.moderation-error { color:#dc3545; }
|
||||||
|
|
||||||
|
|
@ -240,18 +50,16 @@ p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn { padding:12px 30px; border:none; border-radius:6px; cursor:pointer; font-size:16px; transition:background-color 0.2s; 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; }
|
||||||
.btn-warning:hover { background:#e0a800; }
|
.btn-warning:hover { background:#e0a800; }
|
||||||
.btn-danger { background:#dc3545; color:white; }
|
.btn-danger { background:#dc3545; color:white; }
|
||||||
.btn-danger:hover { background:#c82333; }
|
.btn-danger:hover { background:#c82333; }
|
||||||
.btn:disabled { opacity:0.65; cursor:not-allowed; }
|
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
|
||||||
|
|
||||||
/* 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; }
|
||||||
|
|
@ -285,38 +93,3 @@ p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MATERIAL-UI OVERRIDES - Globale Schriftart
|
|
||||||
============================================ */
|
|
||||||
/* TextField, Input, Textarea */
|
|
||||||
.MuiTextField-root input,
|
|
||||||
.MuiTextField-root textarea,
|
|
||||||
.MuiInputBase-root,
|
|
||||||
.MuiInputBase-input,
|
|
||||||
.MuiOutlinedInput-input {
|
|
||||||
font-family: 'Open Sans', sans-serif !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Labels */
|
|
||||||
.MuiFormLabel-root,
|
|
||||||
.MuiInputLabel-root,
|
|
||||||
.MuiTypography-root {
|
|
||||||
font-family: 'Open Sans', sans-serif !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.MuiButton-root {
|
|
||||||
font-family: 'Open Sans', sans-serif !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox Labels */
|
|
||||||
.MuiFormControlLabel-label {
|
|
||||||
font-family: 'Open Sans', sans-serif !important;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user