feat: Add comprehensive test suite and admin API authentication

🧪 Testing Infrastructure (45 tests, 100% passing)
- Implemented Jest + Supertest framework for automated testing
- Unit tests: 5 tests for auth middleware (100% coverage)
- Integration tests: 40 tests covering admin, consent, migration, upload APIs
- Test execution time: ~10 seconds for full suite
- Coverage: 26% statements, 15% branches (realistic start)
- In-memory SQLite database for isolated testing
- Singleton server pattern for fast test execution
- Automatic cleanup and teardown

🔒 Admin API Authentication
- Bearer token authentication for all admin endpoints
- requireAdminAuth middleware with ADMIN_API_KEY validation
- Protected routes: /api/admin/*, /api/system/migration/migrate|rollback
- Complete authentication guide in AUTHENTICATION.md
- HTTP 403 for missing/invalid tokens, 500 if not configured
- Ready for production with token rotation support

📋 API Route Documentation
- Single Source of Truth: backend/src/routes/routeMappings.js
- Comprehensive route overview in backend/src/routes/README.md
- Express routing order documented (specific before generic)
- Frontend integration guide with authentication examples
- OpenAPI auto-generation integrated

🐛 Bug Fixes
- Fixed SQLite connection not properly awaited (caused test hangs)
- Fixed upload validation checking req.files.file before req.files
- Fixed Express route order (consent before admin router)
- Fixed test environment using /tmp for uploads (permission issues)

📚 Documentation Updates
- Updated README.md with testing and authentication features
- Updated README.dev.md with testing section and API development guide
- Updated CHANGELOG.md with complete feature documentation
- Updated FEATURE_PLAN-autogen-openapi.md (status: 100% complete)
- Added frontend/MIGRATION-GUIDE.md for frontend team

🚀 Frontend Impact
Frontend needs to add Bearer token to all /api/admin/* calls.
See frontend/MIGRATION-GUIDE.md for detailed instructions.

Test Status:  45/45 passing (100%)
Backend:  Production ready
Frontend: ⚠️ Migration required (see MIGRATION-GUIDE.md)
This commit is contained in:
Matthias Lotz 2025-11-16 18:08:48 +01:00
parent 8e8150331d
commit cdb2aa95e6
42 changed files with 6539 additions and 490 deletions

199
AUTHENTICATION.md Normal file
View File

@ -0,0 +1,199 @@
# API Authentication Guide
## Übersicht
Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unterschiedliche Zugriffslevel:
### 1. Admin-Routes (Bearer Token)
- **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
- **Methode**: Bearer Token im Authorization Header
- **Konfiguration**: `.env``ADMIN_API_KEY`
### 2. Management-Routes (UUID Token)
- **Zweck**: Self-Service Portal für Gruppen-Verwaltung
- **Methode**: UUID v4 Token in URL-Path
- **Quelle**: Automatisch generiert beim Upload, gespeichert in DB
---
## 1. Admin Authentication
### Setup
1. **Sicheren Admin-Key generieren**:
```bash
# Linux/Mac:
openssl rand -hex 32
# Oder Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
2. **In `.env` eintragen**:
```env
ADMIN_API_KEY=dein-generierter-key-hier
```
3. **Server neu starten**
### Verwendung
Alle Requests an `/api/admin/*` benötigen den Authorization Header:
```bash
curl -H "Authorization: Bearer dein-generierter-key-hier" \
http://localhost:5000/api/admin/deletion-log
```
**Postman/Insomnia**:
- Type: `Bearer Token`
- Token: `dein-generierter-key-hier`
### Geschützte Endpoints
| Endpoint | Method | Beschreibung |
|----------|--------|--------------|
| `/api/admin/deletion-log` | GET | Deletion Log Einträge |
| `/api/admin/deletion-log/csv` | GET | Deletion Log als CSV |
| `/api/admin/cleanup/run` | POST | Cleanup manuell starten |
| `/api/admin/cleanup/status` | GET | Cleanup Status |
| `/api/admin/rate-limiter/stats` | GET | Rate-Limiter Statistiken |
| `/api/admin/management-audit` | GET | Management Audit Log |
| `/api/admin/groups` | GET | Alle Gruppen (Moderation) |
| `/api/admin/groups/:id/approve` | PUT | Gruppe freigeben |
| `/api/admin/groups/:id` | DELETE | Gruppe löschen |
### Error Codes
| Status | Bedeutung |
|--------|-----------|
| `403` | Authorization header fehlt oder ungültig |
| `500` | ADMIN_API_KEY nicht konfiguriert |
---
## 2. Management Authentication
### Setup
**Kein Setup nötig!** Token werden automatisch generiert.
### Funktionsweise
1. **Bei Upload** wird automatisch ein UUID v4 Token generiert
2. **Token wird gespeichert** in DB (Spalte: `management_token`)
3. **Token wird zurückgegeben** in der Upload-Response
4. **Nutzer erhält Link** wie: `https://example.com/manage/{token}`
### Verwendung
Token wird **im URL-Path** übergeben (nicht im Header):
```bash
# Token validieren und Daten laden
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
# Bilder hochladen
POST /api/manage/550e8400-e29b-41d4-a716-446655440000/images
# Gruppe löschen
DELETE /api/manage/550e8400-e29b-41d4-a716-446655440000
```
### Geschützte Endpoints
| Endpoint | Method | Beschreibung |
|----------|--------|--------------|
| `/api/manage/:token` | GET | Gruppen-Daten laden |
| `/api/manage/:token/consents` | PUT | Social Media Consents |
| `/api/manage/:token/metadata` | PUT | Metadaten bearbeiten |
| `/api/manage/:token/images` | POST | Bilder hinzufügen |
| `/api/manage/:token/images/:imageId` | DELETE | Bild löschen |
| `/api/manage/:token` | DELETE | Gruppe löschen |
### Sicherheits-Features
- **Token-Format Validierung**: Nur gültige UUID v4 Tokens
- **Rate Limiting**: Schutz vor Brute-Force
- **Audit Logging**: Alle Aktionen werden geloggt
- **DB-Check**: Token muss in DB existieren
### Error Codes
| Status | Bedeutung |
|--------|-----------|
| `404` | Token nicht gefunden oder Gruppe gelöscht |
| `429` | Rate Limit überschritten |
---
## Testing
### Unit Tests
```bash
npm test -- tests/unit/auth.test.js
```
### Integration Tests
```bash
# Admin Auth testen
npm test -- tests/api/admin-auth.test.js
# Alle API Tests
npm test
```
### Manuelles Testen
**Admin-Route ohne Auth**:
```bash
curl http://localhost:5000/api/admin/deletion-log
# → 403 Forbidden
```
**Admin-Route mit Auth**:
```bash
curl -H "Authorization: Bearer your-key" \
http://localhost:5000/api/admin/deletion-log
# → 200 OK
```
---
## Production Checklist
- [ ] `ADMIN_API_KEY` mit sicherem 64-Zeichen Key setzen
- [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
- [ ] HTTPS verwenden (TLS/SSL)
- [ ] Rate Limiting aktiviert prüfen
- [ ] Audit Logs regelmäßig überprüfen
- [ ] Token-Rotation Policy für Admin-Key implementieren
---
## Sicherheits-Hinweise
### Admin-Key Rotation
Admin-Key regelmäßig erneuern (z.B. alle 90 Tage):
1. Neuen Key generieren
2. `.env` aktualisieren
3. Server neu starten
4. Alte Clients auf neuen Key umstellen
### Management-Token
- Token sind **permanent gültig** bis Gruppe gelöscht wird
- Bei Verdacht auf Leak: Gruppe löschen (löscht auch Token)
- Token-Format (UUID v4) macht Brute-Force unpraktisch
### Best Practices
- Admin-Key **nie** im Code hart-kodieren
- Admin-Key **nie** in Logs/Errors ausgeben
- Requests über HTTPS (kein Plain-HTTP in Production)
- Rate-Limiting für beide Auth-Typen aktiv
- Audit-Logs regelmäßig auf Anomalien prüfen

View File

@ -2,6 +2,106 @@
## [Unreleased] - Branch: feature/SocialMedia
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
#### Testing Infrastructure
- ✅ **Jest + Supertest Framework**: 45 automated tests covering all API endpoints
- Unit tests: 5 tests for authentication middleware (100% coverage)
- Integration tests: 40 tests for API endpoints
- Test success rate: 100% (45/45 passing)
- Execution time: ~10 seconds for full suite
- ✅ **Test Organization**:
- `tests/unit/` - Unit tests (auth.test.js)
- `tests/api/` - Integration tests (admin, consent, migration, upload)
- `tests/setup.js` - Global configuration with singleton server pattern
- `tests/testServer.js` - Test server helper utilities
- ✅ **Test Environment**:
- In-memory SQLite database (`:memory:`) for isolation
- Temporary upload directories (`/tmp/test-image-uploader/`)
- Singleton server pattern for fast test execution
- Automatic cleanup after test runs
- `NODE_ENV=test` environment detection
- ✅ **Code Coverage**:
- Statements: 26% (above 20% threshold)
- Branches: 15%
- Functions: 16%
- Lines: 26%
#### Admin API Authentication
- ✅ **Bearer Token Security**: Protected all admin and dangerous system endpoints
- `requireAdminAuth` middleware for Bearer token validation
- Environment variable: `ADMIN_API_KEY` for token configuration
- Protected routes: All `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
- HTTP responses: 403 for invalid/missing tokens, 500 if ADMIN_API_KEY not configured
- ✅ **Authentication Documentation**:
- Complete setup guide in `AUTHENTICATION.md`
- Example token generation commands (openssl, Node.js)
- curl and Postman usage examples
- Security best practices and production checklist
#### API Route Documentation
- ✅ **Single Source of Truth**: `backend/src/routes/routeMappings.js`
- Centralized route configuration for server and OpenAPI generation
- Comprehensive API overview in `backend/src/routes/README.md`
- Critical Express routing order documented and enforced
- ✅ **Route Order Fix**: Fixed Express route matching bug
- Problem: Generic routes (`/groups/:groupId`) matched before specific routes (`/groups/by-consent`)
- Solution: Mount consent router before admin router on `/api/admin` prefix
- Documentation: Added comments explaining why order matters
- ✅ **OpenAPI Auto-Generation**:
- Automatic spec generation on backend start (dev mode)
- Swagger UI available at `/api/docs` in development
- Skip generation in test and production modes
#### Bug Fixes
- 🐛 Fixed: SQLite connection callback not properly awaited (caused test hangs)
- Wrapped `new sqlite3.Database()` in Promise for proper async/await
- 🐛 Fixed: Upload endpoint file validation checking `req.files.file` before `req.files` existence
- Added `!req.files` check before accessing `.file` property
- 🐛 Fixed: Test uploads failing with EACCES permission denied
- Use `/tmp/` directory in test mode instead of `data/images/`
- Dynamic path handling with `path.isAbsolute()` check
- 🐛 Fixed: Express route order causing consent endpoints to return 404
- Reordered routers: consent before admin in routeMappings.js
#### Frontend Impact
**⚠️ Action Required**: Frontend needs updates for new authentication system
1. **Admin API Calls**: Add Bearer token header
```javascript
headers: {
'Authorization': `Bearer ${ADMIN_API_KEY}`
}
```
2. **Route Verification**: Check all API paths against `routeMappings.js`
- Consent routes: `/api/admin/groups/by-consent`, `/api/admin/consents/export`
- Migration routes: `/api/system/migration/*` (not `/api/migration/*`)
3. **Error Handling**: Handle 403 responses for missing/invalid authentication
4. **Environment Configuration**: Add `REACT_APP_ADMIN_API_KEY` to frontend `.env`
#### Technical Details
- **Backend Changes**:
- New files: `middlewares/auth.js`, `tests/` directory structure
- Modified files: All admin routes now protected, upload.js validation improved
- Database: Promisified SQLite connection in DatabaseManager.js
- Constants: Test-mode path handling in constants.js
- **Configuration Files**:
- `jest.config.js`: Test configuration with coverage thresholds
- `.env.example`: Added ADMIN_API_KEY documentation
- `package.json`: Added Jest and Supertest dependencies
---
### 🎨 Modular UI Architecture (November 15, 2025)
#### Features

View File

@ -15,6 +15,7 @@ docker compose -f docker/dev/docker-compose.yml up -d
### Zugriff
- **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv)
- **Backend**: http://localhost:5001 (API)
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
- **Slideshow**: http://localhost:3000/slideshow
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
@ -30,6 +31,80 @@ docker compose -f docker/dev/docker-compose.yml logs -f frontend-dev
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
```
## API-Entwicklung
### Route-Struktur
Die API verwendet eine **Single Source of Truth** für Route-Mappings:
📄 **`backend/src/routes/routeMappings.js`** - Zentrale Route-Konfiguration
Siehe auch: **`backend/src/routes/README.md`** für vollständige API-Übersicht
**Wichtige Route-Gruppen:**
- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints
- `/api/manage/:token` - Self-Service Management Portal (UUID-Token)
- `/api/admin/*` - Admin-Endpoints (Bearer Token Authentication)
- `/api/system/migration/*` - Datenbank-Migrationen
**⚠️ Express Route-Reihenfolge beachten:**
Router mit spezifischen Routes **vor** generischen Routes mounten!
```javascript
// ✅ RICHTIG: Spezifisch vor generisch
{ router: 'consent', prefix: '/api/admin' }, // /groups/by-consent
{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId
// ❌ FALSCH: Generisch fängt alles ab
{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId matched auf 'by-consent'!
{ router: 'consent', prefix: '/api/admin' }, // Wird nie erreicht
```
### Authentication
**Zwei Auth-Systeme parallel:**
1. **Admin API (Bearer Token)**:
```bash
# .env konfigurieren:
ADMIN_API_KEY=your-secure-key-here
# API-Aufrufe:
curl -H "Authorization: Bearer your-secure-key-here" \
http://localhost:5001/api/admin/groups
```
2. **Management Portal (UUID Token)**:
```bash
# Automatisch beim Upload generiert
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
```
📖 **Vollständige Doku**: `AUTHENTICATION.md`
### OpenAPI-Spezifikation
Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert:
```bash
# Generiert: backend/docs/openapi.json
# Swagger UI: http://localhost:5001/api/docs
# Manuelle Generierung:
cd backend
node src/generate-openapi.js
```
**Swagger-Annotationen in Routes:**
```javascript
router.get('/example', async (req, res) => {
/*
#swagger.tags = ['Example']
#swagger.summary = 'Get example data'
#swagger.responses[200] = { description: 'Success' }
*/
});
```
## Entwicklung
### Frontend-Entwicklung
@ -50,9 +125,11 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
- Environment: `NODE_ENV=development`
**Wichtige Module:**
- `routes/routeMappings.js` - Single Source of Truth für Route-Konfiguration
- `repositories/GroupRepository.js` - Consent-Management & CRUD
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
- `routes/batchUpload.js` - Upload mit Consent-Validierung
- `middlewares/auth.js` - Admin Authentication (Bearer Token)
- `database/DatabaseManager.js` - Automatische Migrationen
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik
@ -95,6 +172,63 @@ docker compose -f docker/dev/docker-compose.yml logs backend-dev | grep -i migra
## Testing
### Automatisierte Tests
Das Backend verfügt über eine umfassende Test-Suite mit 45 Tests:
```bash
# Alle Tests ausführen:
cd backend
npm test
# Einzelne Test-Suite:
npm test -- tests/api/admin.test.js
# Mit Coverage-Report:
npm test -- --coverage
# Watch-Mode (während Entwicklung):
npm test -- --watch
```
**Test-Struktur:**
- `tests/unit/` - Unit-Tests (z.B. Auth-Middleware)
- `tests/api/` - Integration-Tests (API-Endpoints)
- `tests/setup.js` - Globale Test-Konfiguration
- `tests/testServer.js` - Test-Server-Helper
**Test-Features:**
- Jest + Supertest Framework
- In-Memory SQLite Database (isoliert)
- Singleton Server Pattern (schnell)
- 100% Test-Success-Rate (45/45 passing)
- ~10 Sekunden Ausführungszeit
- Coverage: 26% Statements, 15% Branches
**Test-Umgebung:**
- Verwendet `/tmp/test-image-uploader/` für Upload-Tests
- Eigene Datenbank `:memory:` (kein Konflikt mit Dev-DB)
- Environment: `NODE_ENV=test`
- Automatisches Cleanup nach Test-Run
**Neue Tests hinzufügen:**
```javascript
// tests/api/example.test.js
const { getRequest } = require('../testServer');
describe('Example API', () => {
it('should return 200', async () => {
const response = await getRequest()
.get('/api/example')
.expect(200);
expect(response.body).toHaveProperty('data');
});
});
```
### Manuelles Testing
### Consent-System testen
```bash
# 1. Upload mit und ohne Workshop-Consent

View File

@ -21,6 +21,32 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
### 🆕 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)
@ -181,7 +207,11 @@ The application automatically generates optimized preview thumbnails for all upl
### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication)
- **Authentication**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
- **Authentication Methods**:
- **Frontend**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
- **API Direct Access**: Bearer Token via `Authorization: Bearer <ADMIN_API_KEY>` header
- See `AUTHENTICATION.md` for detailed setup instructions
- **Protected Endpoints**: All `/api/admin/*` routes require authentication
- **Features**:
- Review pending image groups before public display
- Visual countdown showing days until automatic deletion (7 days for unapproved groups)

View File

@ -10,6 +10,11 @@ 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

297
backend/TESTING.md Normal file
View File

@ -0,0 +1,297 @@
# API Testing Guide
## 🎯 Testing-Strategie
### 1. Smoke Tests (Aktuell)
**Script:** `test-openapi-paths.js`
**Was wird getestet:**
- ✅ Alle Pfade sind erreichbar
- ✅ Server antwortet
- ❌ Keine Schema-Validierung
- ❌ Keine Auth-Tests
- ❌ Keine Request Bodies
**Verwendung:**
```bash
npm run test-openapi
```
**Zweck:** Schneller Check ob alle Routen mounted sind.
---
## 🔧 Empfohlene Testing-Tools
### Option 1: Dredd (Contract Testing) ⭐ EMPFOHLEN
**Installation:**
```bash
npm install --save-dev dredd
```
**Setup:**
```bash
# dredd.yml erstellen
cat > dredd.yml << EOF
color: true
dry-run: false
hookfiles: null
language: nodejs
require: null
server: npm start
server-wait: 3
init: false
custom: {}
names: false
only: []
reporter: []
output: []
header: []
sorted: false
user: null
inline-errors: false
details: false
method: []
loglevel: warning
path: []
blueprint: docs/openapi.json
endpoint: http://localhost:5000
EOF
```
**Ausführen:**
```bash
npx dredd
```
**Vorteile:**
- ✅ Validiert Responses gegen OpenAPI-Schema
- ✅ Testet alle HTTP-Methoden
- ✅ Prüft Request/Response Bodies
- ✅ Unterstützt Hooks für Auth
---
### Option 2: Postman + Newman
**Setup:**
1. OpenAPI importieren:
- Öffne Postman
- File → Import → `backend/docs/openapi.json`
2. Collection generieren lassen
3. Tests hinzufügen (automatisch oder manuell)
4. Export als Collection
**CLI-Tests:**
```bash
npm install --save-dev newman
npx newman run postman_collection.json
```
**Vorteile:**
- ✅ GUI für manuelles Testen
- ✅ Automatische Test-Generierung
- ✅ CI/CD Integration
- ✅ Environment Variables
---
### Option 3: Prism (Mock + Validate)
**Installation:**
```bash
npm install --save-dev @stoplight/prism-cli
```
**Mock Server starten:**
```bash
npx prism mock docs/openapi.json
# Server läuft auf http://localhost:4010
```
**Validierung:**
```bash
npx prism validate docs/openapi.json
```
**Vorteile:**
- ✅ Mock Server für Frontend-Entwicklung
- ✅ Schema-Validierung
- ✅ Contract Testing
- ✅ Keine echte API nötig
---
### Option 4: Jest + Supertest (Unit/Integration Tests)
**Installation:**
```bash
npm install --save-dev jest supertest jest-openapi
```
**Beispiel Test:**
```javascript
// tests/api/groups.test.js
const request = require('supertest');
const jestOpenAPI = require('jest-openapi');
const app = require('../../src/server');
// Load OpenAPI spec
jestOpenAPI(require('../../docs/openapi.json'));
describe('Groups API', () => {
it('GET /api/groups should return valid response', async () => {
const response = await request(app)
.get('/api/groups')
.expect(200);
// Validate against OpenAPI schema
expect(response).toSatisfyApiSpec();
// Custom assertions
expect(response.body).toHaveProperty('groups');
expect(Array.isArray(response.body.groups)).toBe(true);
});
it('GET /api/groups/:groupId should return 404 for invalid ID', async () => {
const response = await request(app)
.get('/api/groups/invalid-id')
.expect(404);
expect(response).toSatisfyApiSpec();
});
});
```
**package.json:**
```json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
```
**Vorteile:**
- ✅ Vollständige Integration Tests
- ✅ Schema-Validierung mit jest-openapi
- ✅ Code Coverage
- ✅ CI/CD Integration
- ✅ Watch Mode für TDD
---
## 📊 Vergleich
| Tool | Setup | Schema Validation | Auth Support | CI/CD | Best For |
|------|-------|-------------------|--------------|-------|----------|
| **test-openapi-paths.js** | ✅ Fertig | ❌ | ❌ | ✅ | Quick smoke test |
| **Dredd** | ⚠️ Mittel | ✅ | ✅ | ✅ | Contract testing |
| **Postman/Newman** | ⚠️ Mittel | ✅ | ✅ | ✅ | Manual + automated |
| **Prism** | ✅ Easy | ✅ | ⚠️ | ✅ | Mock + validate |
| **Jest + Supertest** | ⚠️ Komplex | ✅ | ✅ | ✅ | Full testing suite |
---
## 🚀 Empfehlung für dieses Projekt
### Phase 1: Quick Wins (Jetzt)
```bash
# 1. Behalte aktuelles Smoke-Test Script
npm run test-openapi
# 2. Installiere Prism für Validierung
npm install --save-dev @stoplight/prism-cli
# 3. Validiere OpenAPI Spec
npm run validate-openapi # (siehe unten)
```
### Phase 2: Contract Testing (Später)
```bash
# 1. Installiere Dredd
npm install --save-dev dredd
# 2. Erstelle dredd.yml (siehe oben)
# 3. Schreibe Hooks für Auth
# tests/dredd-hooks.js
# 4. Führe aus
npm run test:contract
```
### Phase 3: Full Testing Suite (Optional)
```bash
# Jest + Supertest + jest-openapi
npm install --save-dev jest supertest jest-openapi @types/jest
# Schreibe Integration Tests in tests/
npm run test
```
---
## 📝 Neue NPM Scripts
Füge zu `package.json` hinzu:
```json
{
"scripts": {
"test-openapi": "node test-openapi-paths.js",
"validate-openapi": "prism validate docs/openapi.json",
"test:contract": "dredd",
"test": "jest",
"test:watch": "jest --watch"
}
}
```
---
## 🔗 Swagger-eigene Test-Tools
**Swagger Inspector:**
- Online: https://inspector.swagger.io/
- Import `openapi.json`
- Manuelles Testen in GUI
- Kann Test-Collections exportieren
**Swagger Codegen:**
```bash
# Generiert Client-Code + Tests
npx @openapitools/openapi-generator-cli generate \
-i docs/openapi.json \
-g javascript \
-o generated-client
```
**Swagger UI "Try it out":**
- Bereits aktiv unter http://localhost:5000/api/docs
- Manuelles Testing direkt in Browser
- ✅ Einfachstes Tool für Quick Tests
---
## ✅ Fazit
**Aktuell:** `test-openapi-paths.js` ist OK für Smoke Tests.
**Nächster Schritt:**
1. Installiere **Prism** für Schema-Validierung
2. Später: **Dredd** für Contract Testing
3. Optional: **Jest** für vollständige Test-Suite
**Best Practice:** Kombiniere mehrere Tools:
- Prism für Schema-Validierung
- Dredd für Contract Testing
- Jest für Unit/Integration Tests
- Swagger UI für manuelles Testing

2361
backend/docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

29
backend/jest.config.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
testEnvironment: 'node',
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.js',
'!src/index.js', // Server startup
'!src/generate-openapi.js', // Build tool
'!src/scripts/**', // Utility scripts
],
testMatch: [
'**/tests/**/*.test.js',
'**/tests/**/*.spec.js'
],
coverageThreshold: {
global: {
branches: 20,
functions: 20,
lines: 20,
statements: 20
}
},
// Setup for each test file - initializes server once
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
// Run tests serially to avoid DB conflicts
maxWorkers: 1,
// Force exit after tests complete
forceExit: true
};

View File

@ -9,7 +9,13 @@
"client": "npm run dev --prefix ../frontend",
"client-build": "cd ../frontend && npm run build && serve -s build -l 80",
"dev": "concurrently \"npm run server\" \"npm run client\"",
"build": "concurrently \"npm run server\" \"npm run client-build\""
"build": "concurrently \"npm run server\" \"npm run client-build\"",
"generate-openapi": "node src/generate-openapi.js",
"test-openapi": "node test-openapi-paths.js",
"validate-openapi": "redocly lint docs/openapi.json",
"test": "jest --coverage",
"test:watch": "jest --watch",
"test:api": "jest tests/api"
},
"keywords": [],
"author": "",
@ -27,7 +33,13 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@redocly/cli": "^2.11.1",
"@stoplight/prism-cli": "^5.14.2",
"concurrently": "^6.0.0",
"nodemon": "^2.0.7"
"jest": "^30.2.0",
"nodemon": "^2.0.7",
"supertest": "^7.1.4",
"swagger-autogen": "^2.23.7",
"swagger-ui-express": "^5.0.1"
}
}

View File

@ -1,21 +1,15 @@
const endpoints = {
UPLOAD_STATIC_DIRECTORY: '/upload',
UPLOAD_FILE: '/upload',
UPLOAD_BATCH: '/upload/batch',
PREVIEW_STATIC_DIRECTORY: '/previews',
DOWNLOAD_FILE: '/download/:id',
GET_GROUP: '/groups/:groupId',
GET_ALL_GROUPS: '/groups',
DELETE_GROUP: '/groups/:groupId'
};
// Filesystem directory (relative to backend/src) where uploaded images will be stored
// Use path.join(__dirname, '..', UPLOAD_FS_DIR, fileName) in code
const UPLOAD_FS_DIR = 'data/images';
// In test mode, use a temporary directory in /tmp to avoid permission issues
const UPLOAD_FS_DIR = process.env.NODE_ENV === 'test'
? '/tmp/test-image-uploader/images'
: 'data/images';
// Filesystem directory (relative to backend/src) where preview images will be stored
// Use path.join(__dirname, '..', PREVIEW_FS_DIR, fileName) in code
const PREVIEW_FS_DIR = 'data/previews';
const PREVIEW_FS_DIR = process.env.NODE_ENV === 'test'
? '/tmp/test-image-uploader/previews'
: 'data/previews';
// Preview generation configuration
const PREVIEW_CONFIG = {
@ -29,4 +23,4 @@ const time = {
WEEK_1: 604800000
};
module.exports = { endpoints, time, UPLOAD_FS_DIR, PREVIEW_FS_DIR, PREVIEW_CONFIG };
module.exports = { time, UPLOAD_FS_DIR, PREVIEW_FS_DIR, PREVIEW_CONFIG };

View File

@ -5,28 +5,38 @@ const fs = require('fs');
class DatabaseManager {
constructor() {
this.db = null;
// Use in-memory database for tests, file-based for production
if (process.env.NODE_ENV === 'test') {
this.dbPath = ':memory:';
} else {
// Place database file under data/db
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
}
this.schemaPath = path.join(__dirname, 'schema.sql');
}
async initialize() {
try {
// Stelle sicher, dass das data-Verzeichnis existiert
// Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
if (this.dbPath !== ':memory:') {
const dataDir = path.dirname(this.dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
}
// Öffne Datenbankverbindung
// Öffne Datenbankverbindung (promisify for async/await)
await new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('Fehler beim Öffnen der Datenbank:', err.message);
throw err;
reject(err);
} else {
console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
resolve();
}
});
});
// Aktiviere Foreign Keys
await this.run('PRAGMA foreign_keys = ON');
@ -37,8 +47,10 @@ class DatabaseManager {
// Run database migrations (automatic on startup)
await this.runMigrations();
// Generate missing previews for existing images
// Generate missing previews for existing images (skip in test mode)
if (process.env.NODE_ENV !== 'test') {
await this.generateMissingPreviews();
}
console.log('✓ Datenbank erfolgreich initialisiert');
} catch (error) {

View File

@ -0,0 +1,96 @@
const swaggerAutogen = require('swagger-autogen')();
const path = require('path');
const fs = require('fs');
const outputFile = path.join(__dirname, '..', 'docs', 'openapi.json');
// Import route mappings (Single Source of Truth - keine Router-Imports!)
const routeMappings = require('./routes/routeMappings');
// Use mappings directly (already has file + prefix)
const routerMappings = routeMappings;
const routesDir = path.join(__dirname, 'routes');
const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
const doc = {
info: {
title: 'Project Image Uploader API',
version: '1.0.0',
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
},
host: 'localhost:5000',
schemes: ['http'],
// 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)
};
console.log('Generating OpenAPI spec...');
// Generate specs for each router separately with correct basePath
async function generateWithPrefixes() {
const allPaths = {};
const allTags = new Set();
for (const mapping of routerMappings) {
console.log(`<EFBFBD> Processing ${mapping.file} with prefix: "${mapping.prefix || '/'}"...`);
const uniqueName = mapping.name || mapping.file.replace('.js', '');
const tempOutput = path.join(__dirname, '..', 'docs', `.temp-${uniqueName}.json`);
const routeFile = path.join(routesDir, mapping.file);
const tempDoc = {
...doc,
basePath: mapping.prefix || '/'
};
await swaggerAutogen(tempOutput, [routeFile], tempDoc);
// Read the generated spec
const tempSpec = JSON.parse(fs.readFileSync(tempOutput, 'utf8'));
// Merge paths - prepend prefix to each path
for (const [routePath, pathObj] of Object.entries(tempSpec.paths || {})) {
const fullPath = mapping.prefix + routePath;
allPaths[fullPath] = pathObj;
// Collect tags
Object.values(pathObj).forEach(methodObj => {
if (methodObj.tags) {
methodObj.tags.forEach(tag => allTags.add(tag));
}
});
}
// Clean up temp file
fs.unlinkSync(tempOutput);
}
// Write final merged spec
const finalSpec = {
openapi: '3.0.0',
info: doc.info,
servers: [
{ url: 'http://localhost:5000', description: 'Development server' }
],
tags: Array.from(allTags).map(name => ({ name })),
paths: allPaths
};
fs.writeFileSync(outputFile, JSON.stringify(finalSpec, null, 2));
console.log('\n✅ OpenAPI spec generated successfully!');
console.log(`📊 Total paths: ${Object.keys(allPaths).length}`);
console.log(`📋 Tags: ${Array.from(allTags).join(', ')}`);
}
// Export for programmatic usage (e.g., from server.js)
module.exports = generateWithPrefixes;
// Run directly when called from CLI
if (require.main === module) {
generateWithPrefixes().catch(err => {
console.error('❌ Failed to generate OpenAPI spec:', err);
process.exit(1);
});
}

View File

@ -0,0 +1,50 @@
/**
* Admin Authentication Middleware
* Validates Bearer token from Authorization header against ADMIN_API_KEY env variable
*/
const requireAdminAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
// Check if Authorization header exists
if (!authHeader) {
return res.status(403).json({
error: 'Zugriff verweigert',
message: 'Authorization header fehlt'
});
}
// 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();
};
module.exports = { requireAdminAuth };

View File

@ -0,0 +1,357 @@
# API Routes - Developer Guide
## 📁 Single Source of Truth
**`routeMappings.js`** ist die zentrale Konfigurationsdatei für alle API-Routen.
```javascript
// ✅ HIER ändern (Single Source of Truth)
module.exports = [
{ router: 'upload', prefix: '/api', file: 'upload.js' },
// ...
];
```
**Verwendet von:**
- `routes/index.js` → Server-Routing
- `generate-openapi.js` → OpenAPI-Dokumentation
**❌ NICHT direkt in `routes/index.js` oder `generate-openapi.js` ändern!**
---
## 🆕 Neue Route hinzufügen
### 1. Router-Datei erstellen
```bash
touch backend/src/routes/myNewRoute.js
```
```javascript
// backend/src/routes/myNewRoute.js
const express = require('express');
const router = express.Router();
/**
* #swagger.tags = ['My Feature']
* #swagger.description = 'Beschreibung der Route'
*/
router.get('/my-endpoint', async (req, res) => {
res.json({ success: true });
});
module.exports = router;
```
### 2. In `routeMappings.js` registrieren
```javascript
// backend/src/routes/routeMappings.js
module.exports = [
// ... bestehende Routes
{ router: 'myNewRoute', prefix: '/api/my-feature', file: 'myNewRoute.js' }
];
```
### 3. In `routes/index.js` importieren
```javascript
// backend/src/routes/index.js
const myNewRouteRouter = require('./myNewRoute');
const routerMap = {
// ... bestehende Router
myNewRoute: myNewRouteRouter
};
```
### 4. OpenAPI regenerieren
OpenAPI wird **automatisch** bei jedem Server-Start (Dev-Mode) generiert.
**Manuell generieren:**
```bash
npm run generate-openapi
```
**OpenAPI-Pfade testen:**
```bash
npm run test-openapi # Prüft alle Routen gegen localhost:5000
```
**Fertig!** Route ist unter `/api/my-feature/my-endpoint` verfügbar.
---
## 🔄 OpenAPI-Dokumentation generieren
### Automatisch bei Server-Start (Dev-Mode) ⭐
Im Development-Modus wird die OpenAPI-Spezifikation **automatisch generiert**, wenn der Server startet:
```bash
cd backend
npm run dev # oder npm run server
```
**Ausgabe:**
```
🔄 Generating OpenAPI specification...
✓ OpenAPI spec generated
📊 Total paths: 35
📋 Tags: Upload, Management Portal, Admin - ...
```
Die Datei `backend/docs/openapi.json` wird bei jedem Start aktualisiert.
### Manuell (für Produktions-Builds)
```bash
cd backend
npm run generate-openapi
```
**Generiert:** `backend/docs/openapi.json`
**Zugriff:** http://localhost:5000/api/docs (nur dev-mode)
### Was wird generiert?
- Alle Routen aus `routeMappings.js`
- Mount-Prefixes werden automatisch angewendet
- Swagger-Annotations aus Route-Dateien werden erkannt
- **Automatisch im Dev-Mode:** Bei jedem Server-Start (nur wenn `NODE_ENV !== 'production'`)
- **Manuell:** Mit `npm run generate-openapi`
### Swagger-Annotations verwenden
**Wichtig:** swagger-autogen nutzt `#swagger` Comments (nicht `@swagger`)!
```javascript
router.get('/groups', async (req, res) => {
/*
#swagger.tags = ['Groups']
#swagger.summary = 'Alle Gruppen abrufen'
#swagger.description = 'Liefert alle freigegebenen Gruppen mit Bildern'
#swagger.responses[200] = {
description: 'Liste der Gruppen',
schema: {
groups: [{
groupId: 'cTV24Yn-a',
year: 2024,
title: 'Familie Mueller'
}],
totalCount: 73
}
}
#swagger.responses[500] = {
description: 'Server error'
}
*/
// Route implementation...
});
```
**Mit Parametern:**
```javascript
router.get('/groups/:groupId', async (req, res) => {
/*
#swagger.tags = ['Groups']
#swagger.summary = 'Einzelne Gruppe abrufen'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Unique group ID',
example: 'cTV24Yn-a'
}
#swagger.responses[200] = {
description: 'Group details',
schema: { groupId: 'cTV24Yn-a', title: 'Familie Mueller' }
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
// Route implementation...
});
```
**Mit Request Body:**
```javascript
router.post('/groups', async (req, res) => {
/*
#swagger.tags = ['Groups']
#swagger.summary = 'Neue Gruppe erstellen'
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
title: 'Familie Mueller',
year: 2024,
description: 'Weihnachtsfeier'
}
}
#swagger.responses[201] = {
description: 'Group created',
schema: { groupId: 'abc123', message: 'Created successfully' }
}
*/
// Route implementation...
});
```
---
## 🗂️ API-Struktur
### Public API (`/api`)
- **Zugriff:** Öffentlich, keine Authentifizierung
- **Routen:** Upload, Download, Groups (lesend)
- **Dateien:** `upload.js`, `download.js`, `batchUpload.js`, `groups.js`
### Management API (`/api/manage`)
- **Zugriff:** Token-basiert (UUID v4)
- **Routen:** Selbstverwaltung von eigenen Gruppen
- **Dateien:** `management.js`
- **Beispiel:** `PUT /api/manage/:token/reorder`
### Admin API (`/api/admin`)
- **Zugriff:** Geschützt (Middleware erforderlich)
- **Routen:** Moderation, Deletion Logs, Cleanup
- **Dateien:** `admin.js`, `consent.js`, `reorder.js`
- **Beispiel:** `GET /api/admin/groups`, `DELETE /api/admin/groups/:id`
### System API (`/api/system`)
- **Zugriff:** Intern (Wartungsfunktionen)
- **Routen:** Datenbank-Migrationen
- **Dateien:** `migration.js`
---
## 🔒 Mehrfach-Mount (z.B. Reorder)
Manche Routen sind an mehreren Stellen verfügbar:
```javascript
// routeMappings.js
module.exports = [
// Admin-Zugriff (geschützt)
{ router: 'reorder', prefix: '/api/admin', file: 'reorder.js' },
// Management-Zugriff (in management.js integriert)
// { router: 'management', prefix: '/api/manage', file: 'management.js' }
// → enthält PUT /:token/reorder
];
```
**Hinweis:** Reorder ist direkt in `management.js` implementiert, nicht als separater Mount.
---
## ⚠️ Wichtige Regeln
### 1. Relative Pfade in Router-Dateien
```javascript
// ✅ RICHTIG (ohne Prefix)
router.get('/groups', ...)
router.get('/groups/:id', ...)
// ❌ FALSCH (Prefix gehört in routeMappings.js)
router.get('/api/groups', ...)
```
### 2. String-Literale verwenden
```javascript
// ✅ RICHTIG
router.get('/upload', ...)
// ❌ FALSCH (swagger-autogen kann Variablen nicht auflösen)
const ROUTES = { UPLOAD: '/upload' };
router.get(ROUTES.UPLOAD, ...)
```
### 3. Mount-Prefix nur in routeMappings.js
```javascript
// routeMappings.js
{ router: 'groups', prefix: '/api', file: 'groups.js' }
// ✅ Ergebnis: /api/groups
```
---
## 🧪 Testen
### Backend-Tests mit curl
```bash
# Public API
curl http://localhost:5000/api/groups
# Management API (Token erforderlich)
curl http://localhost:5000/api/manage/YOUR-TOKEN-HERE
# Admin API
curl http://localhost:5000/api/admin/groups
```
### OpenAPI-Spec validieren
```bash
cd backend
npm run test-openapi
```
**Ausgabe:**
```
🔍 Testing 35 paths from openapi.json against http://localhost:5000
✅ GET /api/groups → 200
✅ GET /api/upload → 405 (expected, needs POST)
...
```
### Swagger UI öffnen
```
http://localhost:5000/api/docs
```
**Hinweis:** Nur im Development-Modus verfügbar!
---
## 🐛 Troubleshooting
### OpenAPI-Generierung hängt
**Problem:** `generate-openapi.js` lädt Router-Module, die wiederum andere Module laden → Zirkelbezüge
**Lösung:** `routeMappings.js` enthält nur Konfiguration, keine Router-Imports
### Route nicht in OpenAPI
1. Prüfe `routeMappings.js` → Route registriert?
2. Prüfe Router-Datei → String-Literale verwendet?
3. Regeneriere: `npm run generate-openapi` (oder starte Server neu im Dev-Mode)
### Route funktioniert nicht
1. Prüfe `routes/index.js` → Router in `routerMap` eingetragen?
2. Prüfe Console → Fehler beim Server-Start?
3. Teste mit curl → Exakte URL prüfen
---
## 📚 Weitere Dokumentation
- **Feature-Plan:** `docs/FEATURE_PLAN-autogen-openapi.md`
- **OpenAPI-Spec:** `backend/docs/openapi.json`
- **API-Tests:** `backend/test-openapi-paths.js`

View File

@ -2,14 +2,41 @@ const express = require('express');
const router = express.Router();
const DeletionLogRepository = require('../repositories/DeletionLogRepository');
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
const GroupRepository = require('../repositories/GroupRepository');
const GroupCleanupService = require('../services/GroupCleanupService');
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
const { requireAdminAuth } = require('../middlewares/auth');
// GroupCleanupService ist bereits eine Instanz, keine Klasse
const cleanupService = GroupCleanupService;
// Hole Deletion Log (mit Limit)
// Apply admin authentication to ALL routes in this router
router.use(requireAdminAuth);
router.get('/deletion-log', async (req, res) => {
/*
#swagger.tags = ['Admin - Deletion Log']
#swagger.summary = 'Get recent deletion log entries'
#swagger.description = 'Returns recent deletion log entries with optional limit'
#swagger.parameters['limit'] = {
in: 'query',
type: 'integer',
description: 'Number of entries to return (1-1000)',
example: 10
}
#swagger.responses[200] = {
description: 'Deletion log entries',
schema: {
success: true,
deletions: [],
total: 2,
limit: 10
}
}
#swagger.responses[400] = {
description: 'Invalid limit parameter'
}
*/
try {
const limit = parseInt(req.query.limit) || 10;
@ -38,8 +65,20 @@ router.get('/deletion-log', async (req, res) => {
}
});
// Hole alle Deletion Logs
router.get('/deletion-log/all', async (req, res) => {
/*
#swagger.tags = ['Admin - Deletion Log']
#swagger.summary = 'Get all deletion log entries'
#swagger.description = 'Returns complete deletion log without pagination'
#swagger.responses[200] = {
description: 'All deletion log entries',
schema: {
success: true,
deletions: [],
total: 50
}
}
*/
try {
const deletions = await DeletionLogRepository.getAllDeletions();
@ -57,8 +96,23 @@ router.get('/deletion-log/all', async (req, res) => {
}
});
// Hole Deletion Statistiken
router.get('/deletion-log/stats', async (req, res) => {
/*
#swagger.tags = ['Admin - Deletion Log']
#swagger.summary = 'Get deletion statistics'
#swagger.description = 'Returns aggregated statistics about deleted images'
#swagger.responses[200] = {
description: 'Deletion statistics',
schema: {
success: true,
totalDeleted: 12,
totalImages: 348,
totalSize: '19.38 MB',
totalSizeBytes: 20324352,
lastCleanup: '2025-11-15T10:30:00Z'
}
}
*/
try {
const stats = await DeletionLogRepository.getDeletionStatistics();
@ -88,8 +142,20 @@ router.get('/deletion-log/stats', async (req, res) => {
}
});
// Manueller Cleanup-Trigger (für Testing)
router.post('/cleanup/trigger', async (req, res) => {
/*
#swagger.tags = ['Admin - Cleanup']
#swagger.summary = 'Manually trigger cleanup of unapproved groups'
#swagger.description = 'Deletes groups that have not been approved within retention period'
#swagger.responses[200] = {
description: 'Cleanup completed',
schema: {
success: true,
deletedGroups: 3,
message: '3 alte unbestätigte Gruppen gelöscht'
}
}
*/
try {
console.log('[Admin API] Manual cleanup triggered');
const result = await cleanupService.performScheduledCleanup();
@ -108,8 +174,27 @@ router.post('/cleanup/trigger', async (req, res) => {
}
});
// Zeige welche Gruppen gelöscht würden (Dry-Run)
router.get('/cleanup/preview', async (req, res) => {
/*
#swagger.tags = ['Admin - Cleanup']
#swagger.summary = 'Preview groups that would be deleted'
#swagger.description = 'Dry-run showing which unapproved groups are eligible for deletion'
#swagger.responses[200] = {
description: 'Preview of groups to delete',
schema: {
success: true,
groupsToDelete: 2,
groups: [{
id: 'abc123',
groupName: 'Familie_Mueller',
uploadDate: '2025-10-01T12:00:00Z',
daysUntilDeletion: -5,
imageCount: 8
}],
message: '2 groups would be deleted'
}
}
*/
try {
const groups = await cleanupService.findGroupsForDeletion();
@ -137,8 +222,21 @@ router.get('/cleanup/preview', async (req, res) => {
});
// Rate-Limiter Statistiken (für Monitoring)
router.get('/rate-limiter/stats', async (req, res) => {
/*
#swagger.tags = ['Admin - Monitoring']
#swagger.summary = 'Get rate limiter statistics'
#swagger.description = 'Returns statistics about rate limiting (blocked requests, active limits)'
#swagger.responses[200] = {
description: 'Rate limiter statistics',
schema: {
success: true,
totalRequests: 1523,
blockedRequests: 12,
activeClients: 45
}
}
*/
try {
const stats = getRateLimiterStats();
@ -155,8 +253,30 @@ router.get('/rate-limiter/stats', async (req, res) => {
}
});
// Management Audit-Log (letzte N Einträge)
router.get('/management-audit', async (req, res) => {
/*
#swagger.tags = ['Admin - Monitoring']
#swagger.summary = 'Get management audit log entries'
#swagger.description = 'Returns recent management portal activity logs'
#swagger.parameters['limit'] = {
in: 'query',
type: 'integer',
description: 'Number of entries to return (1-1000)',
example: 100
}
#swagger.responses[200] = {
description: 'Audit log entries',
schema: {
success: true,
logs: [],
total: 15,
limit: 100
}
}
#swagger.responses[400] = {
description: 'Invalid limit parameter'
}
*/
try {
const limit = parseInt(req.query.limit) || 100;
@ -184,8 +304,25 @@ router.get('/management-audit', async (req, res) => {
}
});
// Management Audit-Log Statistiken
router.get('/management-audit/stats', async (req, res) => {
/*
#swagger.tags = ['Admin - Monitoring']
#swagger.summary = 'Get management audit log statistics'
#swagger.description = 'Returns aggregated statistics about management portal activity'
#swagger.responses[200] = {
description: 'Audit log statistics',
schema: {
success: true,
totalActions: 523,
actionsByType: {
'update': 312,
'delete': 45,
'approve': 166
},
lastAction: '2025-11-15T14:30:00Z'
}
}
*/
try {
const stats = await ManagementAuditLogRepository.getStatistics();
@ -202,8 +339,28 @@ router.get('/management-audit/stats', async (req, res) => {
}
});
// Management Audit-Log nach Group-ID
router.get('/management-audit/group/:groupId', async (req, res) => {
/*
#swagger.tags = ['Admin - Monitoring']
#swagger.summary = 'Get audit log for specific group'
#swagger.description = 'Returns all management actions performed on a specific group'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.responses[200] = {
description: 'Audit log for group',
schema: {
success: true,
groupId: 'abc123def456',
logs: [],
total: 8
}
}
*/
try {
const { groupId } = req.params;
const logs = await ManagementAuditLogRepository.getLogsByGroupId(groupId);
@ -223,5 +380,571 @@ router.get('/management-audit/group/:groupId', async (req, res) => {
}
});
// ============================================================================
// GRUPPEN-MODERATION (verschoben von groups.js)
// ============================================================================
router.get('/groups', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Get all groups for moderation'
#swagger.description = 'Returns all groups including unapproved ones with moderation info and consent data'
#swagger.parameters['workshopOnly'] = {
in: 'query',
type: 'boolean',
description: 'Filter by workshop consent',
example: false
}
#swagger.parameters['platform'] = {
in: 'query',
type: 'string',
description: 'Filter by social media platform',
example: 'instagram'
}
#swagger.responses[200] = {
description: 'All groups with moderation info',
schema: {
success: true,
groups: [{
groupId: 'abc123',
groupName: 'Familie_Mueller',
isApproved: false,
uploadDate: '2025-11-01T10:00:00Z',
imageCount: 12,
socialMediaConsents: []
}]
}
}
*/
try {
const { workshopOnly, platform } = req.query;
// Hole alle Gruppen mit vollständigen Infos (inkl. Bilder)
let allGroups = await GroupRepository.getAllGroupsWithModerationInfo();
// Füge Consent-Daten für jede Gruppe hinzu
const groupsWithConsents = await Promise.all(
allGroups.map(async (group) => {
const consents = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId);
return {
...group,
socialMediaConsents: consents
};
})
);
// Jetzt filtern wir basierend auf den Query-Parametern
let filteredGroups = groupsWithConsents;
if (workshopOnly === 'true') {
// Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents
filteredGroups = groupsWithConsents.filter(group => {
// Muss Werkstatt-Consent haben
if (!group.display_in_workshop) return false;
// Darf KEINE zugestimmten Social Media Consents haben
const hasConsentedSocialMedia = group.socialMediaConsents &&
group.socialMediaConsents.some(consent => consent.consented === 1 || consent.consented === true);
return !hasConsentedSocialMedia;
});
} else if (platform) {
// Filter: Gruppen mit bestimmter Social Media Platform (unabhängig vom Werkstatt-Consent)
filteredGroups = groupsWithConsents.filter(group =>
group.socialMediaConsents &&
group.socialMediaConsents.some(consent =>
consent.platform_name === platform && (consent.consented === 1 || consent.consented === true)
)
);
}
// else: Kein Filter - zeige ALLE Gruppen (nicht filtern)
res.json({
groups: filteredGroups,
totalCount: filteredGroups.length,
pendingCount: filteredGroups.filter(g => !g.approved).length,
approvedCount: filteredGroups.filter(g => g.approved).length
});
} catch (error) {
console.error('Error fetching moderation groups:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Moderations-Gruppen',
details: error.message
});
}
});
router.get('/groups/:groupId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Get single group for moderation'
#swagger.description = 'Returns detailed info for a specific group including unapproved ones'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.responses[200] = {
description: 'Group details with images',
schema: {
groupId: 'abc123',
groupName: 'Familie_Mueller',
isApproved: true,
images: []
}
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
const group = await GroupRepository.getGroupForModeration(groupId);
if (!group) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json(group);
} catch (error) {
console.error('Error fetching group for moderation:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Gruppe für Moderation',
details: error.message
});
}
});
router.patch('/groups/:groupId/approve', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Approve a group'
#swagger.description = 'Marks a group as approved, making it publicly visible'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['body'] = {
in: 'body',
required: false,
schema: {
approved: true
}
}
#swagger.responses[200] = {
description: 'Group approved successfully',
schema: {
success: true,
message: 'Gruppe erfolgreich freigegeben'
}
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
const { approved } = req.body;
// Validierung
if (typeof approved !== 'boolean') {
return res.status(400).json({
error: 'Invalid request',
message: 'approved muss ein boolean Wert sein'
});
}
const updated = await GroupRepository.updateGroupApproval(groupId, approved);
if (!updated) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt',
groupId: groupId,
approved: approved
});
} catch (error) {
console.error('Error updating group approval:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Freigabe'
});
}
});
router.patch('/groups/:groupId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Update group metadata'
#swagger.description = 'Updates group metadata fields (year, title, description, name)'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
year: 2025,
title: 'Sommercamp',
description: 'Tolle Veranstaltung',
name: 'Familie_Mueller'
}
}
#swagger.responses[200] = {
description: 'Group updated successfully',
schema: {
success: true,
message: 'Gruppe aktualisiert',
updatedFields: ['year', 'title']
}
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
// Erlaubte Felder zum Aktualisieren
const allowed = ['year', 'title', 'description', 'name'];
const updates = {};
for (const field of allowed) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Keine gültigen Felder zum Aktualisieren angegeben'
});
}
const updated = await GroupRepository.updateGroup(groupId, updates);
if (!updated) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Gruppe erfolgreich aktualisiert',
groupId: groupId,
updates: updates
});
} catch (error) {
console.error('Error updating group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Gruppe',
details: error.message
});
}
});
router.delete('/groups/:groupId/images/:imageId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Delete a single image'
#swagger.description = 'Deletes a specific image from a group'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['imageId'] = {
in: 'path',
required: true,
type: 'integer',
description: 'Image ID',
example: 42
}
#swagger.responses[200] = {
description: 'Image deleted successfully',
schema: {
success: true,
message: 'Bild erfolgreich gelöscht',
groupId: 'abc123def456',
imageId: 42
}
}
#swagger.responses[404] = {
description: 'Image not found'
}
*/
try {
const { groupId, imageId } = req.params;
const deleted = await GroupRepository.deleteImage(groupId, parseInt(imageId));
if (!deleted) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bild erfolgreich gelöscht',
groupId: groupId,
imageId: parseInt(imageId)
});
} catch (error) {
console.error('Error deleting image:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen des Bildes'
});
}
});
router.patch('/groups/:groupId/images/batch-description', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Batch update image descriptions'
#swagger.description = 'Updates descriptions for multiple images in a group at once'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
descriptions: [
{ imageId: 1, description: 'Sonnenuntergang am Strand' },
{ imageId: 2, description: 'Gruppenfoto beim Lagerfeuer' }
]
}
}
#swagger.responses[200] = {
description: 'Descriptions updated',
schema: {
success: true,
updatedCount: 2,
message: '2 Bildbeschreibungen aktualisiert'
}
}
#swagger.responses[400] = {
description: 'Invalid request format'
}
*/
try {
const { groupId } = req.params;
const { descriptions } = req.body;
// Validierung
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'descriptions muss ein nicht-leeres Array sein'
});
}
// Validiere jede Beschreibung
for (const desc of descriptions) {
if (!desc.imageId || typeof desc.imageId !== 'number') {
return res.status(400).json({
error: 'Invalid request',
message: 'Jede Beschreibung muss eine gültige imageId enthalten'
});
}
if (desc.description && desc.description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: `Bildbeschreibung für Bild ${desc.imageId} darf maximal 200 Zeichen lang sein`
});
}
}
const result = await GroupRepository.updateBatchImageDescriptions(groupId, descriptions);
res.json({
success: true,
message: `${result.updatedImages} Bildbeschreibungen erfolgreich aktualisiert`,
groupId: groupId,
updatedImages: result.updatedImages
});
} catch (error) {
console.error('Error batch updating image descriptions:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibungen',
details: error.message
});
}
});
router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Update single image description'
#swagger.description = 'Updates description for a specific image (max 200 characters)'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.parameters['imageId'] = {
in: 'path',
required: true,
type: 'integer',
description: 'Image ID',
example: 42
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
image_description: 'Sonnenuntergang am Strand'
}
}
#swagger.responses[200] = {
description: 'Description updated',
schema: {
success: true,
message: 'Bildbeschreibung erfolgreich aktualisiert',
groupId: 'abc123def456',
imageId: 42,
imageDescription: 'Sonnenuntergang am Strand'
}
}
#swagger.responses[400] = {
description: 'Description too long (max 200 chars)'
}
#swagger.responses[404] = {
description: 'Image not found'
}
*/
try {
const { groupId, imageId } = req.params;
const { image_description } = req.body;
// Validierung: Max 200 Zeichen
if (image_description && image_description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: 'Bildbeschreibung darf maximal 200 Zeichen lang sein'
});
}
const updated = await GroupRepository.updateImageDescription(
parseInt(imageId),
groupId,
image_description
);
if (!updated) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bildbeschreibung erfolgreich aktualisiert',
groupId: groupId,
imageId: parseInt(imageId),
imageDescription: image_description
});
} catch (error) {
console.error('Error updating image description:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibung',
details: error.message
});
}
});
router.delete('/groups/:groupId', async (req, res) => {
/*
#swagger.tags = ['Admin - Groups Moderation']
#swagger.summary = 'Delete a group'
#swagger.description = 'Deletes a complete group including all images and metadata'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.responses[200] = {
description: 'Group deleted successfully',
schema: {
success: true,
message: 'Gruppe erfolgreich gelöscht',
groupId: 'abc123def456'
}
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
const deleted = await GroupRepository.deleteGroup(groupId);
if (!deleted) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Gruppe erfolgreich gelöscht',
groupId: groupId
});
} catch (error) {
console.error('Error deleting group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen der Gruppe'
});
}
});
module.exports = router;

View File

@ -2,7 +2,6 @@ const generateId = require("shortid");
const express = require('express');
const { Router } = require('express');
const path = require('path');
const { endpoints } = require('../constants');
const UploadGroup = require('../models/uploadGroup');
const groupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager');
@ -10,8 +9,81 @@ const ImagePreviewService = require('../services/ImagePreviewService');
const router = Router();
/**
* @swagger
* /upload/batch:
* post:
* tags: [Upload]
* summary: Batch upload multiple images and create a group
* description: Uploads multiple images at once, creates previews, and stores them as a group with metadata and consent information
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required:
* - images
* - consents
* properties:
* images:
* type: array
* items:
* type: string
* format: binary
* description: Multiple image files to upload
* metadata:
* type: string
* description: JSON string with group metadata (year, title, description, name)
* example: '{"year":2024,"title":"Familie Mueller","description":"Weihnachtsfeier","name":"Mueller"}'
* descriptions:
* type: string
* description: JSON array with image descriptions
* example: '[{"index":0,"description":"Gruppenfoto"},{"index":1,"description":"Werkstatt"}]'
* consents:
* type: string
* description: JSON object with consent flags (workshopConsent is required)
* example: '{"workshopConsent":true,"socialMedia":{"facebook":false,"instagram":true}}'
* responses:
* 200:
* description: Batch upload successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* groupId:
* type: string
* example: "cTV24Yn-a"
* managementToken:
* type: string
* format: uuid
* example: "550e8400-e29b-41d4-a716-446655440000"
* filesProcessed:
* type: integer
* example: 5
* message:
* type: string
* example: "5 Bilder erfolgreich hochgeladen"
* 400:
* description: Bad request - missing files or workshop consent
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* message:
* type: string
* 500:
* description: Server error during batch upload
*/
// Batch-Upload für mehrere Bilder
router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
router.post('/upload/batch', async (req, res) => {
try {
// Überprüfe ob Dateien hochgeladen wurden
if (!req.files || !req.files.images) {

View File

@ -9,16 +9,35 @@ const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository');
const SocialMediaRepository = require('../repositories/SocialMediaRepository');
const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth');
// Schütze alle Consent-Routes mit Admin-Auth
router.use(requireAdminAuth);
// ============================================================================
// Social Media Platforms
// ============================================================================
/**
* GET /api/social-media/platforms
* GET /social-media/platforms
* Liste aller aktiven Social Media Plattformen
*/
router.get('/api/social-media/platforms', async (req, res) => {
router.get('/social-media/platforms', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'Get active social media platforms'
#swagger.description = 'Returns list of all active social media platforms available for consent'
#swagger.responses[200] = {
description: 'List of platforms',
schema: [{
platform_id: 1,
platform_name: 'instagram',
display_name: 'Instagram',
icon_name: 'instagram',
is_active: true
}]
}
*/
try {
const socialMediaRepo = new SocialMediaRepository(dbManager);
const platforms = await socialMediaRepo.getActivePlatforms();
@ -38,7 +57,7 @@ router.get('/api/social-media/platforms', async (req, res) => {
// ============================================================================
/**
* POST /api/groups/:groupId/consents
* POST /groups/:groupId/consents
* Speichere oder aktualisiere Consents für eine Gruppe
*
* Body: {
@ -46,7 +65,7 @@ router.get('/api/social-media/platforms', async (req, res) => {
* socialMediaConsents: [{ platformId: number, consented: boolean }]
* }
*/
router.post('/api/groups/:groupId/consents', async (req, res) => {
router.post('/groups/:groupId/consents', async (req, res) => {
try {
const { groupId } = req.params;
const { workshopConsent, socialMediaConsents } = req.body;
@ -98,10 +117,40 @@ router.post('/api/groups/:groupId/consents', async (req, res) => {
});
/**
* GET /api/groups/:groupId/consents
* GET /groups/:groupId/consents
* Lade alle Consents für eine Gruppe
*/
router.get('/api/groups/:groupId/consents', async (req, res) => {
router.get('/groups/:groupId/consents', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'Get consents for a group'
#swagger.description = 'Returns all consent data (workshop + social media) for a specific group'
#swagger.parameters['groupId'] = {
in: 'path',
required: true,
type: 'string',
description: 'Group ID',
example: 'abc123def456'
}
#swagger.responses[200] = {
description: 'Group consents',
schema: {
groupId: 'abc123',
workshopConsent: true,
consentTimestamp: '2025-11-01T10:00:00Z',
socialMediaConsents: [{
platformId: 1,
platformName: 'instagram',
displayName: 'Instagram',
consented: true,
revoked: false
}]
}
}
#swagger.responses[404] = {
description: 'Group not found'
}
*/
try {
const { groupId } = req.params;
@ -148,7 +197,7 @@ router.get('/api/groups/:groupId/consents', async (req, res) => {
// ============================================================================
/**
* GET /api/admin/groups/by-consent
* GET /groups/by-consent
* Filtere Gruppen nach Consent-Status
*
* Query params:
@ -156,7 +205,43 @@ router.get('/api/groups/:groupId/consents', async (req, res) => {
* - platformId: number
* - platformConsent: boolean
*/
router.get('/api/admin/groups/by-consent', async (req, res) => {
router.get('/groups/by-consent', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'Filter groups by consent status'
#swagger.description = 'Returns groups filtered by workshop consent or social media platform consents'
#swagger.parameters['displayInWorkshop'] = {
in: 'query',
type: 'boolean',
description: 'Filter by workshop consent',
example: true
}
#swagger.parameters['platformId'] = {
in: 'query',
type: 'integer',
description: 'Filter by platform ID',
example: 1
}
#swagger.parameters['platformConsent'] = {
in: 'query',
type: 'boolean',
description: 'Filter by platform consent status',
example: true
}
#swagger.responses[200] = {
description: 'Filtered groups',
schema: {
count: 5,
filters: {
displayInWorkshop: true
},
groups: []
}
}
#swagger.responses[400] = {
description: 'Invalid platformId'
}
*/
try {
const filters = {};
@ -199,7 +284,7 @@ router.get('/api/admin/groups/by-consent', async (req, res) => {
});
/**
* GET /api/admin/consents/export
* GET /consents/export
* Export Consent-Daten für rechtliche Dokumentation
*
* Query params:
@ -207,7 +292,54 @@ router.get('/api/admin/groups/by-consent', async (req, res) => {
* - year: number (optional filter)
* - approved: boolean (optional filter)
*/
router.get('/api/admin/consents/export', async (req, res) => {
router.get('/consents/export', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'Export consent data'
#swagger.description = 'Exports consent data for legal documentation in JSON or CSV format'
#swagger.parameters['format'] = {
in: 'query',
type: 'string',
enum: ['json', 'csv'],
description: 'Export format',
example: 'json'
}
#swagger.parameters['year'] = {
in: 'query',
type: 'integer',
description: 'Filter by year',
example: 2025
}
#swagger.parameters['approved'] = {
in: 'query',
type: 'boolean',
description: 'Filter by approval status',
example: true
}
#swagger.responses[200] = {
description: 'Export data (JSON format)',
schema: {
exportDate: '2025-11-15T16:30:00Z',
filters: { year: 2025 },
count: 12,
data: []
}
}
#swagger.responses[200] = {
description: 'Export data (CSV format)',
content: {
'text/csv': {
schema: {
type: 'string',
format: 'binary'
}
}
}
}
#swagger.responses[400] = {
description: 'Invalid format'
}
*/
try {
const format = req.query.format || 'json';
const filters = {};

View File

@ -1,10 +1,36 @@
const { Router } = require('express');
const { endpoints, UPLOAD_FS_DIR } = require('../constants');
const { UPLOAD_FS_DIR } = require('../constants');
const path = require('path');
const router = Router();
router.get(endpoints.DOWNLOAD_FILE, (req, res) => {
/**
* @swagger
* /download/{id}:
* get:
* tags: [Download]
* summary: Download an uploaded image file
* description: Downloads the original image file by filename
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* example: "abc123.jpg"
* description: Filename of the image to download
* responses:
* 200:
* description: File download initiated
* content:
* image/*:
* schema:
* type: string
* format: binary
* 404:
* description: File not found
*/
router.get('/download/:id', (req, res) => {
const filePath = path.join(__dirname, '..', UPLOAD_FS_DIR, req.params.id);
res.download(filePath);
});

View File

@ -1,12 +1,57 @@
const { Router } = require('express');
const { endpoints } = require('../constants');
const GroupRepository = require('../repositories/GroupRepository');
const MigrationService = require('../services/MigrationService');
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)
router.get(endpoints.GET_ALL_GROUPS, async (req, res) => {
router.get('/groups', async (req, res) => {
try {
// Auto-Migration beim ersten Zugriff
const migrationStatus = await MigrationService.getMigrationStatus();
@ -30,93 +75,52 @@ router.get(endpoints.GET_ALL_GROUPS, async (req, res) => {
}
});
// Alle Gruppen für Moderation abrufen (mit Freigabestatus) - MUSS VOR den :groupId routen stehen!
router.get('/moderation/groups', async (req, res) => {
try {
const { workshopOnly, platform } = req.query;
// Hole alle Gruppen mit vollständigen Infos (inkl. Bilder)
let allGroups = await GroupRepository.getAllGroupsWithModerationInfo();
// Füge Consent-Daten für jede Gruppe hinzu
const groupsWithConsents = await Promise.all(
allGroups.map(async (group) => {
const consents = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId);
return {
...group,
socialMediaConsents: consents
};
})
);
// Jetzt filtern wir basierend auf den Query-Parametern
let filteredGroups = groupsWithConsents;
if (workshopOnly === 'true') {
// Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents
filteredGroups = groupsWithConsents.filter(group => {
// Muss Werkstatt-Consent haben
if (!group.display_in_workshop) return false;
// Darf KEINE zugestimmten Social Media Consents haben
const hasConsentedSocialMedia = group.socialMediaConsents &&
group.socialMediaConsents.some(consent => consent.consented === 1 || consent.consented === true);
return !hasConsentedSocialMedia;
});
} else if (platform) {
// Filter: Gruppen mit bestimmter Social Media Platform (unabhängig vom Werkstatt-Consent)
filteredGroups = groupsWithConsents.filter(group =>
group.socialMediaConsents &&
group.socialMediaConsents.some(consent =>
consent.platform_name === platform && (consent.consented === 1 || consent.consented === true)
)
);
}
// else: Kein Filter - zeige ALLE Gruppen (nicht filtern)
res.json({
groups: filteredGroups,
totalCount: filteredGroups.length,
pendingCount: filteredGroups.filter(g => !g.approved).length,
approvedCount: filteredGroups.filter(g => g.approved).length
});
} catch (error) {
console.error('Error fetching moderation groups:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Moderations-Gruppen',
details: error.message
});
}
});
// Einzelne Gruppe für Moderation abrufen (inkl. nicht-freigegebene)
router.get('/moderation/groups/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const group = await GroupRepository.getGroupForModeration(groupId);
if (!group) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json(group);
} catch (error) {
console.error('Error fetching group for moderation:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Laden der Gruppe für Moderation',
details: error.message
});
}
});
// Einzelne Gruppe abrufen
router.get(endpoints.GET_GROUP, 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)
router.get('/groups/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const group = await GroupRepository.getGroupById(groupId);
@ -139,243 +143,4 @@ router.get(endpoints.GET_GROUP, async (req, res) => {
}
});
// Gruppe freigeben/genehmigen
router.patch('/groups/:groupId/approve', async (req, res) => {
try {
const { groupId } = req.params;
const { approved } = req.body;
// Validierung
if (typeof approved !== 'boolean') {
return res.status(400).json({
error: 'Invalid request',
message: 'approved muss ein boolean Wert sein'
});
}
const updated = await GroupRepository.updateGroupApproval(groupId, approved);
if (!updated) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt',
groupId: groupId,
approved: approved
});
} catch (error) {
console.error('Error updating group approval:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Freigabe'
});
}
});
// Gruppe bearbeiten (Metadaten aktualisieren)
router.patch('/groups/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
// Erlaubte Felder zum Aktualisieren
const allowed = ['year', 'title', 'description', 'name'];
const updates = {};
for (const field of allowed) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Keine gültigen Felder zum Aktualisieren angegeben'
});
}
const updated = await GroupRepository.updateGroup(groupId, updates);
if (!updated) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Gruppe erfolgreich aktualisiert',
groupId: groupId,
updates: updates
});
} catch (error) {
console.error('Error updating group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Gruppe',
details: error.message
});
}
});
// Einzelnes Bild löschen
router.delete('/groups/:groupId/images/:imageId', async (req, res) => {
try {
const { groupId, imageId } = req.params;
const deleted = await GroupRepository.deleteImage(groupId, parseInt(imageId));
if (!deleted) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bild erfolgreich gelöscht',
groupId: groupId,
imageId: parseInt(imageId)
});
} catch (error) {
console.error('Error deleting image:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen des Bildes'
});
}
});
// Batch-Update für mehrere Bildbeschreibungen (MUSS VOR der einzelnen Route stehen!)
router.patch('/groups/:groupId/images/batch-description', async (req, res) => {
try {
const { groupId } = req.params;
const { descriptions } = req.body;
// Validierung
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'descriptions muss ein nicht-leeres Array sein'
});
}
// Validiere jede Beschreibung
for (const desc of descriptions) {
if (!desc.imageId || typeof desc.imageId !== 'number') {
return res.status(400).json({
error: 'Invalid request',
message: 'Jede Beschreibung muss eine gültige imageId enthalten'
});
}
if (desc.description && desc.description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: `Bildbeschreibung für Bild ${desc.imageId} darf maximal 200 Zeichen lang sein`
});
}
}
const result = await GroupRepository.updateBatchImageDescriptions(groupId, descriptions);
res.json({
success: true,
message: `${result.updatedImages} Bildbeschreibungen erfolgreich aktualisiert`,
groupId: groupId,
updatedImages: result.updatedImages
});
} catch (error) {
console.error('Error batch updating image descriptions:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibungen',
details: error.message
});
}
});
// Einzelne Bildbeschreibung aktualisieren
router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
try {
const { groupId, imageId } = req.params;
const { image_description } = req.body;
// Validierung: Max 200 Zeichen
if (image_description && image_description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: 'Bildbeschreibung darf maximal 200 Zeichen lang sein'
});
}
const updated = await GroupRepository.updateImageDescription(
parseInt(imageId),
groupId,
image_description
);
if (!updated) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bildbeschreibung erfolgreich aktualisiert',
groupId: groupId,
imageId: parseInt(imageId),
imageDescription: image_description
});
} catch (error) {
console.error('Error updating image description:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibung',
details: error.message
});
}
});
// Gruppe löschen
router.delete(endpoints.DELETE_GROUP, async (req, res) => {
try {
const { groupId } = req.params;
const deleted = await GroupRepository.deleteGroup(groupId);
if (!deleted) {
return res.status(404).json({
error: 'Group not found',
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Gruppe erfolgreich gelöscht',
groupId: groupId
});
} catch (error) {
console.error('Error deleting group:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Löschen der Gruppe'
});
}
});
module.exports = router;

View File

@ -8,11 +8,26 @@ const adminRouter = require('./admin');
const consentRouter = require('./consent');
const managementRouter = require('./management');
// Import route mappings (Single Source of Truth!)
const routeMappingsConfig = require('./routeMappings');
// Map router names to actual router instances
const routerMap = {
upload: uploadRouter,
download: downloadRouter,
batchUpload: batchUploadRouter,
groups: groupsRouter,
migration: migrationRouter,
reorder: reorderRouter,
admin: adminRouter,
consent: consentRouter,
management: managementRouter
};
const renderRoutes = (app) => {
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter, consentRouter].forEach(router => app.use('/', router));
app.use('/groups', reorderRouter);
app.use('/api/admin', adminRouter);
app.use('/api/manage', managementRouter);
routeMappingsConfig.forEach(({ router, prefix }) => {
app.use(prefix, routerMap[router]);
});
};
module.exports = { renderRoutes };

View File

@ -25,6 +25,35 @@ const validateToken = (token) => {
* @throws {500} Server error
*/
router.get('/:token', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Validate token and load group data'
#swagger.description = 'Validates management token and returns complete group data with images and consents'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.responses[200] = {
description: 'Group data loaded successfully',
schema: {
success: true,
data: {
groupId: 'abc123',
groupName: 'Familie_Mueller',
managementToken: '550e8400-e29b-41d4-a716-446655440000',
images: [],
socialMediaConsents: [],
display_in_workshop: true
}
}
}
#swagger.responses[404] = {
description: 'Invalid token or group deleted'
}
*/
try {
const { token } = req.params;
@ -85,6 +114,44 @@ router.get('/:token', async (req, res) => {
* @throws {500} Server error
*/
router.put('/:token/consents', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Revoke or restore consents'
#swagger.description = 'Updates workshop or social media consents for a group'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
consentType: 'workshop',
action: 'revoke',
platformId: 1
}
}
#swagger.responses[200] = {
description: 'Consent updated successfully',
schema: {
success: true,
message: 'Workshop consent revoked successfully',
data: {
consentType: 'workshop',
newValue: false
}
}
}
#swagger.responses[400] = {
description: 'Invalid request parameters'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { consentType, action, platformId } = req.body;
@ -229,6 +296,42 @@ router.put('/:token/consents', async (req, res) => {
* @throws {500} Server error
*/
router.put('/:token/images/descriptions', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Batch update image descriptions'
#swagger.description = 'Updates descriptions for multiple images in a group (max 200 chars each)'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
descriptions: [
{ imageId: 1, description: 'Sonnenuntergang' },
{ imageId: 2, description: 'Gruppenfoto' }
]
}
}
#swagger.responses[200] = {
description: 'Descriptions updated',
schema: {
success: true,
message: '2 image descriptions updated successfully',
updatedCount: 2
}
}
#swagger.responses[400] = {
description: 'Invalid request or description too long'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { descriptions } = req.body;
@ -328,6 +431,45 @@ router.put('/:token/images/descriptions', async (req, res) => {
* @throws {500} Server error
*/
router.put('/:token/metadata', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Update group metadata'
#swagger.description = 'Updates group title, description or name. Sets approved=0 (returns to moderation).'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
title: 'Sommercamp 2025',
description: 'Tolle Veranstaltung',
name: 'Familie_Mueller'
}
}
#swagger.responses[200] = {
description: 'Metadata updated',
schema: {
success: true,
message: 'Metadata updated successfully',
data: {
groupId: 'abc123',
updatedFields: ['title', 'description'],
requiresModeration: true
}
}
}
#swagger.responses[400] = {
description: 'No fields provided'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { title, description, name } = req.body;
@ -425,6 +567,43 @@ router.put('/:token/metadata', async (req, res) => {
* @throws {500} Server error
*/
router.post('/:token/images', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Add new images to group'
#swagger.description = 'Uploads additional images to existing group. Sets approved=0 (requires re-moderation). Max 50 images per group.'
#swagger.consumes = ['multipart/form-data']
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['images'] = {
in: 'formData',
type: 'file',
required: true,
description: 'Image files to upload (JPEG, PNG)'
}
#swagger.responses[200] = {
description: 'Images uploaded',
schema: {
success: true,
message: '3 images added successfully',
data: {
groupId: 'abc123',
newImagesCount: 3,
totalImagesCount: 15
}
}
}
#swagger.responses[400] = {
description: 'No images or limit exceeded (max 50)'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
@ -581,6 +760,43 @@ router.post('/:token/images', async (req, res) => {
* @throws {500} Server error
*/
router.delete('/:token/images/:imageId', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Delete single image'
#swagger.description = 'Deletes a specific image from group (files + DB entry). Sets approved=0. Cannot delete last image.'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['imageId'] = {
in: 'path',
required: true,
type: 'integer',
description: 'Image ID',
example: 42
}
#swagger.responses[200] = {
description: 'Image deleted',
schema: {
success: true,
message: 'Image deleted successfully',
data: {
groupId: 'abc123',
imageId: 42,
remainingImages: 11
}
}
}
#swagger.responses[400] = {
description: 'Cannot delete last image'
}
#swagger.responses[404] = {
description: 'Invalid token or image not found'
}
*/
try {
const { token, imageId } = req.params;
@ -694,6 +910,33 @@ router.delete('/:token/images/:imageId', async (req, res) => {
* @throws {500} Server error
*/
router.delete('/:token', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Delete complete group'
#swagger.description = 'Deletes entire group with all images, consents and metadata. Creates deletion_log entry. Removes all files (originals + previews).'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.responses[200] = {
description: 'Group deleted',
schema: {
success: true,
message: 'Group and all associated data deleted successfully',
data: {
groupId: 'abc123',
imagesDeleted: 12,
deletionTimestamp: '2025-11-15T16:30:00Z'
}
}
}
#swagger.responses[404] = {
description: 'Invalid token or group already deleted'
}
*/
try {
const { token } = req.params;
@ -783,4 +1026,98 @@ 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) => {
try {
const { token } = req.params;
const { imageIds } = req.body;
// Validate token format
if (!validateToken(token)) {
recordFailedTokenValidation(req);
return res.status(400).json({
success: false,
error: 'Invalid management token format'
});
}
// 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`
});
}
// Load group by token to get groupId
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
recordFailedTokenValidation(req);
await res.auditLog('reorder_images', false, null, 'Token not found or group deleted');
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Execute reorder using GroupRepository
const result = await groupRepository.updateImageOrder(groupData.groupId, imageIds);
await res.auditLog('reorder_images', true, groupData.groupId, `Reordered ${result.updatedImages} images`);
res.status(200).json({
success: true,
message: 'Image order updated successfully',
data: result
});
} catch (error) {
console.error(`[MANAGEMENT] Error reordering images for token ${req.params.token}:`, error.message);
await res.auditLog('reorder_images', false, null, error.message);
// Handle specific errors
if (error.message.includes('not found')) {
return res.status(404).json({
success: false,
error: error.message
});
}
if (error.message.includes('Invalid image IDs') ||
error.message.includes('Missing image IDs')) {
return res.status(400).json({
success: false,
error: error.message
});
}
res.status(500).json({
success: false,
error: 'Failed to reorder images'
});
}
});
module.exports = router;

View File

@ -2,11 +2,25 @@ const express = require('express');
const { Router } = require('express');
const MigrationService = require('../services/MigrationService');
const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth');
const router = Router();
// Migration Status abrufen
router.get('/migration/status', async (req, res) => {
router.get('/status', async (req, res) => {
/*
#swagger.tags = ['System Migration']
#swagger.summary = 'Get migration status'
#swagger.description = 'Returns current database migration status and history'
#swagger.responses[200] = {
description: 'Migration status',
schema: {
migrationComplete: true,
jsonBackupExists: true,
sqliteActive: true,
lastMigration: '2025-11-01T10:00:00Z'
}
}
*/
try {
const status = await MigrationService.getMigrationStatus();
res.json(status);
@ -20,8 +34,25 @@ router.get('/migration/status', async (req, res) => {
}
});
// Manuelle Migration starten
router.post('/migration/migrate', async (req, res) => {
// Protect dangerous migration operations with admin auth
router.post('/migrate', requireAdminAuth, async (req, res) => {
/*
#swagger.tags = ['System Migration']
#swagger.summary = 'Manually trigger migration'
#swagger.description = 'Triggers manual migration from JSON to SQLite database'
#swagger.responses[200] = {
description: 'Migration successful',
schema: {
success: true,
message: 'Migration completed successfully',
groupsMigrated: 24,
imagesMigrated: 348
}
}
#swagger.responses[500] = {
description: 'Migration failed'
}
*/
try {
const result = await MigrationService.migrateJsonToSqlite();
res.json(result);
@ -35,8 +66,23 @@ router.post('/migration/migrate', async (req, res) => {
}
});
// Rollback zu JSON (Notfall)
router.post('/migration/rollback', async (req, res) => {
router.post('/rollback', requireAdminAuth, async (req, res) => {
/*
#swagger.tags = ['System Migration']
#swagger.summary = 'Rollback to JSON'
#swagger.description = 'Emergency rollback from SQLite to JSON file storage'
#swagger.responses[200] = {
description: 'Rollback successful',
schema: {
success: true,
message: 'Rolled back to JSON successfully',
groupsRestored: 24
}
}
#swagger.responses[500] = {
description: 'Rollback failed'
}
*/
try {
const result = await MigrationService.rollbackToJson();
res.json(result);
@ -50,8 +96,31 @@ router.post('/migration/rollback', async (req, res) => {
}
});
// Datenbank Health Check
router.get('/migration/health', async (req, res) => {
router.get('/health', async (req, res) => {
/*
#swagger.tags = ['System Migration']
#swagger.summary = 'Database health check'
#swagger.description = 'Checks database connectivity and health status'
#swagger.responses[200] = {
description: 'Database healthy',
schema: {
database: {
healthy: true,
status: 'OK'
}
}
}
#swagger.responses[500] = {
description: 'Database unhealthy',
schema: {
database: {
healthy: false,
status: 'ERROR',
error: 'Connection failed'
}
}
}
*/
try {
const isHealthy = await dbManager.healthCheck();
res.json({

View File

@ -3,24 +3,66 @@ const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository');
/**
* PUT /api/groups/:groupId/reorder
* Reorder images within a group
*
* Request Body:
* {
* "imageIds": [123, 456, 789] // Array of image IDs in new order
* }
*
* Response:
* {
* "success": true,
* "message": "Image order updated successfully",
* "data": {
* "groupId": "abc123",
* "updatedImages": 3,
* "newOrder": [123, 456, 789]
* }
* }
* @swagger
* /{groupId}/reorder:
* put:
* tags: [Admin]
* summary: Reorder images within a group
* description: Updates the display order of images in a group. All image IDs of the group must be provided in the desired order.
* parameters:
* - in: path
* name: groupId
* required: true
* schema:
* type: string
* example: "cTV24Yn-a"
* description: Unique identifier of the group
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - imageIds
* properties:
* imageIds:
* type: array
* items:
* type: integer
* example: [123, 456, 789]
* description: Array of image IDs in the new desired order
* responses:
* 200:
* description: Image order updated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "Image order updated successfully"
* data:
* type: object
* properties:
* groupId:
* type: string
* updatedImages:
* type: integer
* newOrder:
* type: array
* items:
* type: integer
* 400:
* description: Invalid request - missing or invalid imageIds
* 404:
* description: Group not found
* 500:
* description: Server error during reordering
*/
router.put('/:groupId/reorder', async (req, res) => {
try {

View File

@ -0,0 +1,28 @@
/**
* Single Source of Truth für Route-Mappings
* Wird verwendet von:
* - routes/index.js (Server-Routing)
* - generate-openapi.js (OpenAPI-Generierung)
*/
module.exports = [
// Public API - Öffentlich zugänglich
{ router: 'upload', prefix: '/api', file: 'upload.js' },
{ router: 'download', prefix: '/api', file: 'download.js' },
{ router: 'batchUpload', prefix: '/api', file: 'batchUpload.js' },
{ router: 'groups', prefix: '/api', file: 'groups.js' },
// Management API - Token-basierter Zugriff
{ router: 'management', prefix: '/api/manage', file: 'management.js' },
// Admin API - Geschützt (Moderation, Logs, Cleanup, Consents)
// WICHTIG: consent muss VOR admin gemountet werden!
// Grund: admin.js hat /groups/:groupId, das matched auf /groups/by-consent
// Express matched Routes in Reihenfolge → spezifischere zuerst!
{ router: 'consent', prefix: '/api/admin', file: 'consent.js' },
{ router: 'admin', prefix: '/api/admin', file: 'admin.js' },
{ router: 'reorder', prefix: '/api/admin', file: 'reorder.js' },
// System API - Interne Wartungsfunktionen
{ router: 'migration', prefix: '/api/system/migration', file: 'migration.js' }
];

View File

@ -1,7 +1,7 @@
const generateId = require("shortid");
const express = require('express');
const { Router } = require('express');
const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const { UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const path = require('path');
const ImagePreviewService = require('../services/ImagePreviewService');
const groupRepository = require('../repositories/GroupRepository');
@ -10,15 +10,49 @@ const fs = require('fs');
const router = Router();
// Serve uploaded images via URL /upload but store files under data/images
router.use(endpoints.UPLOAD_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) ));
router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) ));
// Serve preview images via URL /previews but store files under data/previews
router.use(endpoints.PREVIEW_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
router.post(endpoints.UPLOAD_FILE, async (req, res) => {
if(req.files === null){
router.post('/upload', async (req, res) => {
/*
#swagger.tags = ['Upload']
#swagger.summary = 'Upload a single image and create a new group'
#swagger.description = 'Uploads an image file, generates a preview, and creates a new group in the database'
#swagger.consumes = ['multipart/form-data']
#swagger.parameters['file'] = {
in: 'formData',
type: 'file',
required: true,
description: 'Image file to upload'
}
#swagger.parameters['groupName'] = {
in: 'formData',
type: 'string',
description: 'Name for the new group',
example: 'Familie Mueller'
}
#swagger.responses[200] = {
description: 'File uploaded successfully',
schema: {
filePath: '/upload/abc123.jpg',
fileName: 'abc123.jpg',
groupId: 'cTV24Yn-a',
groupName: 'Familie Mueller'
}
}
#swagger.responses[400] = {
description: 'No file uploaded',
schema: { msg: 'No file uploaded' }
}
#swagger.responses[500] = {
description: 'Server error during upload'
}
*/
if(!req.files || req.files === null || !req.files.file){
console.log('No file uploaded');
return res.status(400).json({ msg: 'No file uploaded' });
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
const file = req.files.file;
@ -28,7 +62,10 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => {
fileEnding = fileEnding[fileEnding.length - 1]
fileName = generateId() + '.' + fileEnding
const savePath = path.join(__dirname, '..', UPLOAD_FS_DIR, fileName);
// Handle absolute vs relative paths (test mode uses /tmp)
const savePath = path.isAbsolute(UPLOAD_FS_DIR)
? path.join(UPLOAD_FS_DIR, fileName)
: path.join(__dirname, '..', UPLOAD_FS_DIR, fileName);
try {
// Save the uploaded file
@ -72,11 +109,11 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => {
images: [{
fileName: fileName,
originalName: file.name,
filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`,
filePath: `/upload/${fileName}`,
uploadOrder: 1,
fileSize: fileSize,
mimeType: file.mimetype,
previewPath: `${endpoints.PREVIEW_STATIC_DIRECTORY}/${previewFileName}`
previewPath: `/previews/${previewFileName}`
}]
};
@ -87,7 +124,7 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => {
// Return immediately with file path
res.json({
filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`,
filePath: `/upload/${fileName}`,
fileName: fileName,
groupId: groupId,
groupName: groupName

View File

@ -3,6 +3,29 @@ const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager');
const SchedulerService = require('./services/SchedulerService');
// Dev: Auto-generate OpenAPI spec on server start (skip in test mode)
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
try {
console.log('🔄 Generating OpenAPI specification...');
require('./generate-openapi');
console.log('✓ OpenAPI spec generated');
} catch (error) {
console.warn('⚠️ Failed to generate OpenAPI spec:', error.message);
}
}
// Dev: Swagger UI (mount only in non-production)
let swaggerUi, swaggerDocument;
try {
// require lazily — only available/used in dev
swaggerUi = require('swagger-ui-express');
swaggerDocument = require('../docs/openapi.json');
} catch (e) {
// ignore if not installed or file missing
swaggerUi = null;
swaggerDocument = null;
}
class Server {
_port;
_app;
@ -22,6 +45,12 @@ class Server {
// Starte Express Server
initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload'));
// Mount Swagger UI in dev only when available
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
console.log(' Swagger UI mounted at /api/docs (dev only)');
}
this._app.listen(this._port, () => {
console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`);
@ -34,6 +63,23 @@ class Server {
process.exit(1);
}
}
// Expose app for testing
getApp() {
return this._app;
}
// Initialize app without listening (for tests)
async initializeApp() {
await dbManager.initialize();
initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload'));
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}
return this._app;
}
}
module.exports = Server;

View File

@ -7,6 +7,12 @@ class SchedulerService {
}
start() {
// Don't start scheduler in test mode
if (process.env.NODE_ENV === 'test') {
console.log('[Scheduler] Skipped in test mode');
return;
}
console.log('[Scheduler] Starting scheduled tasks...');
// Cleanup-Job: Jeden Tag um 10:00 Uhr

View File

@ -3,7 +3,7 @@ const { renderRoutes } = require('../routes/index');
const removeImages = require('./remove-images');
const fs = require('fs');
const path = require('path');
const { endpoints, UPLOAD_FS_DIR } = require('../constants');
const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const initiateResources = (app) => {
@ -11,12 +11,23 @@ const initiateResources = (app) => {
renderRoutes(app);
// Ensure upload images directory exists: backend/src/../data/images
const imagesDir = path.join(__dirname, '..', UPLOAD_FS_DIR);
// Ensure upload images directory exists
// In test mode, UPLOAD_FS_DIR is absolute (/tmp/...), otherwise relative (data/images)
const imagesDir = path.isAbsolute(UPLOAD_FS_DIR)
? UPLOAD_FS_DIR
: path.join(__dirname, '..', UPLOAD_FS_DIR);
if (!fs.existsSync(imagesDir)){
fs.mkdirSync(imagesDir, { recursive: true });
}
// Ensure preview images directory exists
const previewsDir = path.isAbsolute(PREVIEW_FS_DIR)
? PREVIEW_FS_DIR
: path.join(__dirname, '..', PREVIEW_FS_DIR);
if (!fs.existsSync(previewsDir)){
fs.mkdirSync(previewsDir, { recursive: true });
}
// Ensure db directory exists: backend/src/../data/db
const dbDir = path.join(__dirname, '..', 'data', 'db');
if (!fs.existsSync(dbDir)){

View File

@ -0,0 +1,64 @@
/**
* OpenAPI Path Validator
*
* Basic smoke test to verify all OpenAPI paths are reachable.
* Does NOT replace proper API testing!
*
* For comprehensive testing, consider:
* - Dredd (npm install -g dredd) - Contract testing against OpenAPI spec
* - Postman/Newman - Import openapi.json and run automated tests
* - Prism (npm install -g @stoplight/prism-cli) - Mock server + validation
* - Jest/Supertest - Full integration tests with schema validation
*
* This script only checks if paths respond (basic reachability check).
*/
const fs = require('fs');
const path = require('path');
// Read generated openapi.json
const specPath = path.join(__dirname, 'docs', 'openapi.json');
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
const baseUrl = 'http://localhost:5000';
const paths = Object.keys(spec.paths || {});
console.log(`🔍 Testing ${paths.length} paths from openapi.json against ${baseUrl}\n`);
async function testPath(path, methods) {
const method = Object.keys(methods)[0]; // take first method (usually GET)
const url = `${baseUrl}${path}`;
return new Promise((resolve) => {
const http = require('http');
const req = http.request(url, { method: method.toUpperCase() }, (res) => {
const status = res.statusCode;
const statusEmoji = status === 200 ? '✅' : status === 404 ? '❌' : '⚠️';
console.log(`${statusEmoji} ${method.toUpperCase()} ${path}${status}`);
resolve({ path, status, ok: status === 200 });
});
req.on('error', (err) => {
console.log(`💥 ${method.toUpperCase()} ${path} → ERROR: ${err.message}`);
resolve({ path, status: 'ERROR', ok: false });
});
req.end();
});
}
(async () => {
const results = [];
for (const p of paths) {
const result = await testPath(p, spec.paths[p]);
results.push(result);
}
const failed = results.filter(r => !r.ok);
console.log(`\n📊 Summary: ${results.length} paths tested, ${failed.length} failed\n`);
if (failed.length > 0) {
console.log('❌ Failed paths (likely missing route prefixes):');
failed.forEach(f => console.log(` ${f.path}${f.status}`));
console.log('\n💡 Hint: Generator scanned route files without mount prefixes.');
console.log(' Check backend/src/routes/index.js for app.use() calls with prefixes like /api/admin');
}
})();

View File

@ -0,0 +1,70 @@
const { getRequest } = require('../testServer');
describe('Admin Auth Middleware', () => {
describe('Without Auth Token', () => {
it('should reject requests without Authorization header', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body.message).toContain('Authorization header fehlt');
});
it('should reject requests with invalid Bearer format', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.set('Authorization', 'InvalidFormat token123')
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body.message).toContain('Ungültiges Authorization Format');
});
it('should reject requests with wrong token', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.set('Authorization', 'Bearer wrong-token-123')
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body.message).toContain('Ungültiger Admin-Token');
});
});
describe('With Valid Auth Token', () => {
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-123';
beforeAll(() => {
// Set test admin key
process.env.ADMIN_API_KEY = validToken;
});
it('should allow access with valid Bearer token', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body).toHaveProperty('success');
});
it('should protect all admin endpoints', async () => {
const endpoints = [
'/api/admin/deletion-log',
'/api/admin/rate-limiter/stats',
'/api/admin/management-audit',
'/api/admin/groups'
];
for (const endpoint of endpoints) {
const response = await getRequest()
.get(endpoint)
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body).toBeDefined();
}
});
});
});

View File

@ -0,0 +1,67 @@
const { getRequest } = require('../testServer');
describe('Admin API - Security', () => {
describe('Authentication & Authorization', () => {
const adminEndpoints = [
{ method: 'get', path: '/api/admin/deletion-log' },
{ method: 'get', path: '/api/admin/deletion-log/csv' },
{ method: 'post', path: '/api/admin/cleanup/run' },
{ method: 'get', path: '/api/admin/cleanup/status' },
{ method: 'get', path: '/api/admin/rate-limiter/stats' },
{ method: 'get', path: '/api/admin/management-audit' },
{ method: 'get', path: '/api/admin/groups' },
{ method: 'put', path: '/api/admin/groups/test-id/approve' },
{ method: 'delete', path: '/api/admin/groups/test-id' }
];
adminEndpoints.forEach(({ method, path }) => {
it(`should protect ${method.toUpperCase()} ${path} without authorization`, async () => {
await getRequest()
[method](path)
.expect(403);
});
});
});
describe('GET /api/admin/deletion-log', () => {
it('should require authorization header', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.expect(403);
expect(response.body).toHaveProperty('error');
});
});
describe('GET /api/admin/cleanup/status', () => {
it('should require authorization', async () => {
await getRequest()
.get('/api/admin/cleanup/status')
.expect(403);
});
});
describe('GET /api/admin/rate-limiter/stats', () => {
it('should require authorization', async () => {
await getRequest()
.get('/api/admin/rate-limiter/stats')
.expect(403);
});
});
describe('GET /api/admin/groups', () => {
it('should require authorization', async () => {
await getRequest()
.get('/api/admin/groups')
.expect(403);
});
it('should validate query parameters with authorization', async () => {
// This test would need a valid admin token
// For now, we just test that invalid params are rejected
await getRequest()
.get('/api/admin/groups?status=invalid_status')
.expect(403); // Still 403 without auth, but validates endpoint exists
});
});
});

View File

@ -0,0 +1,125 @@
const { getRequest } = require('../testServer');
describe('Consent Management API', () => {
const validToken = process.env.ADMIN_API_KEY || 'test-admin-key-12345';
describe('GET /api/admin/social-media/platforms', () => {
it('should return list of social media platforms', async () => {
const response = await getRequest()
.get('/api/admin/social-media/platforms')
.set('Authorization', `Bearer ${validToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should include platform metadata', async () => {
const response = await getRequest()
.get('/api/admin/social-media/platforms')
.set('Authorization', `Bearer ${validToken}`);
if (response.body.length > 0) {
const platform = response.body[0];
expect(platform).toHaveProperty('id');
expect(platform).toHaveProperty('platform_name');
expect(platform).toHaveProperty('display_name');
}
});
});
describe('GET /api/admin/groups/:groupId/consents', () => {
it('should return 404 for non-existent group', async () => {
await getRequest()
.get('/api/admin/groups/non-existent-group/consents')
.set('Authorization', `Bearer ${validToken}`)
.expect(404);
});
it('should reject path traversal attempts', async () => {
await getRequest()
.get('/api/admin/groups/../../../etc/passwd/consents')
.set('Authorization', `Bearer ${validToken}`)
.expect(404);
});
});
describe('POST /api/admin/groups/:groupId/consents', () => {
it('should require admin authorization', async () => {
await getRequest()
.post('/api/admin/groups/test-group-id/consents')
.send({ consents: {} })
.expect(403); // No auth header
});
it('should require valid consent data with auth', async () => {
const response = await getRequest()
.post('/api/admin/groups/test-group-id/consents')
.set('Authorization', `Bearer ${validToken}`)
.send({})
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
describe('GET /api/admin/groups/by-consent', () => {
it('should return filtered groups', async () => {
const response = await getRequest()
.get('/api/admin/groups/by-consent')
.set('Authorization', `Bearer ${validToken}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('groups');
expect(response.body).toHaveProperty('count');
expect(Array.isArray(response.body.groups)).toBe(true);
});
it('should accept platform filter', async () => {
const response = await getRequest()
.get('/api/admin/groups/by-consent?platformId=1')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body).toHaveProperty('groups');
expect(response.body).toHaveProperty('filters');
});
it('should accept consent filter', async () => {
const response = await getRequest()
.get('/api/admin/groups/by-consent?displayInWorkshop=true')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.body).toHaveProperty('groups');
expect(response.body.filters).toHaveProperty('displayInWorkshop', true);
});
});
describe('GET /api/admin/consents/export', () => {
it('should require admin authorization', async () => {
await getRequest()
.get('/api/admin/consents/export')
.expect(403);
});
it('should return CSV format with auth and format parameter', async () => {
const response = await getRequest()
.get('/api/admin/consents/export?format=csv')
.set('Authorization', `Bearer ${validToken}`)
.expect(200);
expect(response.headers['content-type']).toMatch(/text\/csv/);
expect(response.headers['content-disposition']).toMatch(/attachment/);
});
it('should include CSV header', async () => {
const response = await getRequest()
.get('/api/admin/consents/export?format=csv')
.set('Authorization', `Bearer ${validToken}`);
expect(response.text).toContain('group_id');
});
});
});

View File

@ -0,0 +1,68 @@
const { getRequest } = require('../testServer');
describe('System Migration API', () => {
describe('GET /api/system/migration/health', () => {
it('should return 200 with healthy status', async () => {
const response = await getRequest()
.get('/api/system/migration/health')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('database');
expect(response.body.database).toHaveProperty('healthy');
expect(response.body.database).toHaveProperty('status');
expect(response.body.database.healthy).toBe(true);
});
it('should include database connection status', async () => {
const response = await getRequest()
.get('/api/system/migration/health');
expect(response.body.database).toHaveProperty('healthy');
expect(typeof response.body.database.healthy).toBe('boolean');
expect(response.body.database.status).toBe('OK');
});
});
describe('GET /api/system/migration/status', () => {
it('should return current migration status', async () => {
const response = await getRequest()
.get('/api/system/migration/status')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('database');
expect(response.body).toHaveProperty('json');
expect(response.body).toHaveProperty('migrated');
expect(response.body).toHaveProperty('needsMigration');
expect(typeof response.body.migrated).toBe('boolean');
});
it('should return migration metadata', async () => {
const response = await getRequest()
.get('/api/system/migration/status');
expect(response.body.database).toHaveProperty('groups');
expect(response.body.database).toHaveProperty('images');
expect(response.body.database).toHaveProperty('initialized');
expect(typeof response.body.database.groups).toBe('number');
expect(typeof response.body.database.images).toBe('number');
});
});
describe('POST /api/system/migration/migrate', () => {
it('should require admin authorization', async () => {
await getRequest()
.post('/api/system/migration/migrate')
.expect(403); // Should be protected by auth
});
});
describe('POST /api/system/migration/rollback', () => {
it('should require admin authorization', async () => {
await getRequest()
.post('/api/system/migration/rollback')
.expect(403);
});
});
});

View File

@ -0,0 +1,58 @@
const { getRequest } = require('../testServer');
const path = require('path');
describe('Upload API', () => {
describe('POST /api/upload', () => {
it('should reject upload without files', async () => {
const response = await getRequest()
.post('/api/upload')
.field('groupName', 'TestGroup')
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/datei|file/i);
});
it('should accept upload with file and groupName', async () => {
// Create a simple test buffer (1x1 transparent PNG)
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
const response = await getRequest()
.post('/api/upload')
.attach('file', testImageBuffer, 'test.png')
.field('groupName', 'TestGroup');
// Log error for debugging
if (response.status !== 200) {
console.log('Upload failed:', response.body);
}
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('filePath');
expect(response.body).toHaveProperty('fileName');
expect(response.body).toHaveProperty('groupId');
expect(response.body).toHaveProperty('groupName', 'TestGroup');
});
it('should use default group name if not provided', async () => {
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
const response = await getRequest()
.post('/api/upload')
.attach('file', testImageBuffer, 'test.png')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveProperty('groupName');
// Should use default: 'Unnamed Group'
expect(response.body.groupName).toBeTruthy();
});
});
});

View File

@ -0,0 +1,33 @@
/**
* Global Setup - Runs ONCE before all test suites
* Initialize test server and database here
*/
const Server = require('../src/server');
module.exports = async () => {
console.log('\n🔧 Global Test Setup - Initializing test server...\n');
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.PORT = 5001;
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
try {
// Create and initialize server
console.log('Creating server instance...');
const serverInstance = new Server(5001);
console.log('Initializing app...');
const app = await serverInstance.initializeApp();
// Store in global scope for all tests
global.__TEST_SERVER__ = serverInstance;
global.__TEST_APP__ = app;
console.log('✅ Test server initialized successfully\n');
} catch (error) {
console.error('❌ Failed to initialize test server:', error);
throw error;
}
};

View File

@ -0,0 +1,14 @@
/**
* Global Teardown - Runs ONCE after all test suites
* Cleanup resources here
*/
module.exports = async () => {
console.log('\n🧹 Global Test Teardown - Cleaning up...\n');
// Cleanup global references
delete global.__TEST_SERVER__;
delete global.__TEST_APP__;
console.log('✅ Test cleanup complete\n');
};

47
backend/tests/setup.js Normal file
View File

@ -0,0 +1,47 @@
/**
* Setup file - Runs before EACH test file
* Initialize server singleton here
*/
const Server = require('../src/server');
// Singleton pattern - initialize only once
let serverInstance = null;
let app = null;
async function initializeTestServer() {
if (!app) {
console.log('🔧 Initializing test server (one-time)...');
process.env.NODE_ENV = 'test';
process.env.PORT = 5001;
process.env.ADMIN_API_KEY = 'test-admin-key-12345';
serverInstance = new Server(5001);
app = await serverInstance.initializeApp();
global.__TEST_SERVER__ = serverInstance;
global.__TEST_APP__ = app;
console.log('✅ Test server ready');
}
return app;
}
// Initialize before all tests
beforeAll(async () => {
await initializeTestServer();
});
// Test timeout
jest.setTimeout(10000);
// Suppress logs during tests
global.console = {
...console,
log: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
error: console.error,
warn: console.warn,
};

View File

@ -0,0 +1,39 @@
const request = require('supertest');
/**
* Get supertest request instance
* Uses globally initialized server from globalSetup.js
*/
function getRequest() {
const app = global.__TEST_APP__;
if (!app) {
throw new Error(
'Test server not initialized. ' +
'This should be handled by globalSetup.js automatically.'
);
}
return request(app);
}
/**
* Legacy compatibility - these are now no-ops
* Server is initialized globally
*/
async function setupTestServer() {
return {
app: global.__TEST_APP__,
serverInstance: global.__TEST_SERVER__
};
}
async function teardownTestServer() {
// No-op - cleanup happens in globalTeardown.js
}
module.exports = {
setupTestServer,
teardownTestServer,
getRequest
};

View File

@ -0,0 +1,81 @@
const { requireAdminAuth } = require('../../src/middlewares/auth');
describe('Auth Middleware Unit Test', () => {
let req, res, next;
beforeEach(() => {
req = { headers: {} };
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
process.env.ADMIN_API_KEY = 'test-key-123';
});
test('should reject missing Authorization header', () => {
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Zugriff verweigert',
message: 'Authorization header fehlt'
})
);
expect(next).not.toHaveBeenCalled();
});
test('should reject invalid Bearer format', () => {
req.headers.authorization = 'Invalid token';
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Ungültiges Authorization Format')
})
);
expect(next).not.toHaveBeenCalled();
});
test('should reject wrong token', () => {
req.headers.authorization = 'Bearer wrong-token';
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Ungültiger Admin-Token'
})
);
expect(next).not.toHaveBeenCalled();
});
test('should allow valid token', () => {
req.headers.authorization = 'Bearer test-key-123';
requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
test('should handle missing ADMIN_API_KEY', () => {
delete process.env.ADMIN_API_KEY;
req.headers.authorization = 'Bearer any-token';
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Server-Konfigurationsfehler'
})
);
expect(next).not.toHaveBeenCalled();
});
});

View File

@ -1,92 +1,195 @@
````markdown
# Feature Plan: Autogenerierte OpenAPI / Swagger Spec
# Feature Plan: Autogenerierte OpenAPI / Swagger Spec + API Restructuring
**Branch:** `feature/autogen-openapi`
**Datum:** 16. November 2025
**Status:** ✅ Complete - Auto-generation active, Single Source of Truth established
## 🎯 Ziel
Einfaches DeveloperExperience Feature, das beim lokalen Start des Backends automatisch eine OpenAPI Spec erzeugt und eine Swagger UI zur Verfügung stellt. Entwickler müssen beim Anlegen neuer Routen die Doku nicht mehr manuell nachpflegen.
## 🎯 Hauptziele
1. ✅ **OpenAPI Auto-Generation:** Swagger Spec wird automatisch aus Route-Definitionen generiert
2. ✅ **Konsistente API-Struktur:** Klare, REST-konforme API-Organisation für einfache KI-Navigation
3. ✅ **Single Source of Truth:** `routeMappings.js` als zentrale Route-Konfiguration
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs` (dev-only)
5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing
6. ✅ **API Security:** Bearer Token Authentication für Admin-Endpoints
---
## 🔍 Scope
- Devonly: Generierung beim lokalen Start oder per npm script.
- Swagger UI unter `/api/docs` (Dev) mit der generierten Spec.
- Optionaler Export der Spec nach `docs/openapi.json` für CI/Review.
- Keine invasive Änderung an Produktionsstart.
## 📊 API-Struktur (Ziel)
### Design-Prinzipien
- **Prefix = Zugriffsebene:** Struktur basiert auf Authentifizierung/Autorisierung
- **REST-konform:** Standard HTTP Methoden (GET, POST, PUT, PATCH, DELETE)
- **KI-freundlich:** Klare Hierarchie, vorhersagbare Patterns
- **Konsistent:** Alle Routen folgen dem gleichen Muster
### Routing-Schema
```
/api/upload (öffentlich - Upload-Funktionen)
/api/groups (öffentlich - Slideshow-Anzeige)
/api/manage/:token/* (token-basiert - User-Verwaltung)
/api/admin/* (geschützt - Moderation)
/api/system/* (intern - Wartung)
```
### Detaillierte Endpunkte
#### 📤 Public API
```
POST /api/upload - Single file upload
POST /api/upload/batch - Batch upload
GET /api/groups - List approved slideshows
GET /api/groups/:groupId - View specific slideshow
```
#### 🔑 Management API
Token-basierter Zugriff für Slideshow-Ersteller:
```
GET /api/manage/:token - Get slideshow info
PUT /api/manage/:token/consents - Update consents
PUT /api/manage/:token/metadata - Update metadata
PUT /api/manage/:token/images/descriptions - Update image descriptions
POST /api/manage/:token/images - Add images
DELETE /api/manage/:token/images/:imageId - Delete image
DELETE /api/manage/:token - Delete slideshow
```
#### 👮 Admin API
Geschützte Moderation- und Management-Funktionen:
```
# Moderation
GET /api/admin/moderation/groups - List pending slideshows
GET /api/admin/moderation/groups/:id - Get slideshow details
PATCH /api/admin/groups/:id/approve - Approve slideshow
PATCH /api/admin/groups/:id - Edit slideshow
DELETE /api/admin/groups/:id/images/:imageId - Delete single image
PATCH /api/admin/groups/:id/images/batch-description
PUT /api/admin/groups/:id/reorder - Reorder images
# Logs & Monitoring
GET /api/admin/deletion-log - Recent deletions
GET /api/admin/deletion-log/stats - Deletion statistics
GET /api/admin/management-audit - Audit log
GET /api/admin/rate-limiter/stats - Rate limiter stats
# Cleanup
POST /api/admin/cleanup/trigger - Trigger cleanup
GET /api/admin/cleanup/preview - Preview cleanup targets
# Consents & Social Media
GET /api/admin/consents/export - Export consents (CSV)
GET /api/admin/social-media/platforms - List platforms
```
#### ⚙️ System API
Interne System-Operationen:
```
GET /api/system/migration/status - Migration status
POST /api/system/migration/migrate - Run migration
POST /api/system/migration/rollback - Rollback migration
GET /api/system/migration/health - Health check
```
---
## 📐 ArchitekturÜberblick
### Warum das funktioniert für dieses Projekt
- Routen werden statisch importiert und in `backend/src/routes/index.js` mit klaren Basispfaden registriert.
- Viele Routen nutzen konstante Pfadangaben (`backend/src/constants.js`) oder feste Strings.
- Daher erkennen Generatoren die meisten Endpunkte zuverlässig.
## 🔧 Technische Implementierung
### Grenzen
- Multipart Uploads (express-fileupload) werden in der Regel als Pfad erkannt, die requestBody Beschreibung (multipart schema) kann fehlen und erfordert kleine Hints.
- Komplexe ResponseSchemas (detaillierte components/schemas) sind nicht automatisch vollständig präzise; diese können später ergänzt werden.
### Komponenten
- **swagger-autogen** (v6.2.8): OpenAPI 3.0 Generation
- **swagger-ui-express** (v4.6.3): Interactive API docs
- **Custom Generator:** `src/generate-openapi.js`
### Generator-Logik
```javascript
// Pro Router-Datei einzeln scannen + Mount-Prefix anwenden
for each routerMapping {
swaggerAutogen(tempFile, [routeFile], { basePath: prefix })
merge paths with prefix into final spec
}
```
### Single Source of Truth
1. **Router-Files (`src/routes/*.js`)**: Enthalten nur relative Pfade
2. **Mount-Konfiguration (`src/routes/index.js`)**: Definiert Prefixes
3. **OpenAPI Generation:** `generate-openapi.js` liest beide und merged
---
## 🔧 ImplementierungsSchritte
### Phase 0 — Vorbereitung (3060 min)
- Branch anlegen: `feature/autogen-openapi` (bereits erfolgt)
- DevDokumentation anpassen: `README.dev.md` Hinweis wie Doku geöffnet wird
## 📚 Für KI-Nutzung
### Phase 1 — MVP Integration (12h)
1. Installiere devDependency: `swagger-autogen` oder `express-oas-generator` (oder `swagger-jsdoc + swagger-ui-express` falls JSDoc bevorzugt wird).
2. Füge ein npm script hinzu: `npm run generate:openapi` (erzeugt `docs/openapi.json`).
3. Bei DevStart: Generator einmal ausführen und Spec in memory oder als file verfügbar machen.
4. Mount Swagger UI unter `/api/docs` nur in Dev.
### API-Hierarchie verstehen
```
/api/* ← Alle API-Endpoints
├─ /upload, /groups ← Öffentlich
├─ /manage/:token/* ← Token-basiert
├─ /admin/* ← Geschützt
└─ /system/* ← Intern
```
**Ergebnis:** Swagger UI listet automatisch erkannte Endpunkte.
### Neue Route hinzufügen
```bash
# 1. Route in passender Datei hinzufügen (z.B. admin.js)
router.get('/new-endpoint', ...)
### Phase 2 — Nacharbeit & Hints (12h)
- Prüfe UploadEndpoints (z. B. `/upload`, `/upload/batch`) und füge ggf. kleine Hints oder manuelle Ergänzungen zur requestBodyDefinition hinzu (in `docs/openapi.json` oder per generator config).
- Optional: ergänze components/schemas für die wichtigsten Responses (Groups, Image, UploadResult).
# 2. In routeMappings.js registrieren (falls neue Datei)
{ router: 'newRoute', prefix: '/api/admin', file: 'newRoute.js' }
### Phase 3 — CI / Export (0.51h)
- Optionales npm script für CI, das `npm run generate:openapi` ausführt und das Ergebnis im Artefact bereitstellt.
- Optional: OpenAPI Validator in CI laufen lassen (z. B. `swagger-cli validate`) um SpecFehler früh zu erkennen.
# 3. OpenAPI wird automatisch beim Backend-Start generiert
npm run dev
# 4. Tests schreiben: tests/api/newRoute.test.js
npm test
# 5. Swagger UI: http://localhost:5001/api/docs
```
---
## ⏱️ Zeitplan & Aufwand
- MVP (Dev UI + generate once): 12 Stunden
- Upload Hints + kleine SpecFixes: 12 Stunden
- Optional: CI/Export + Validator: 0.51 Stunde
## ✅ Implementierungsstatus (November 16, 2025)
**Total (realistisch, MVP+fixes): 24 Stunden**
### Completed Features
**Single Source of Truth**: `routeMappings.js` als zentrale Route-Konfiguration
**Auto-Generation**: OpenAPI-Spec automatisch beim Backend-Start
**Authentication**: Bearer Token für Admin-Endpoints
**Test Suite**: 45 automatisierte Tests (100% passing)
**Documentation**: `routes/README.md` + `AUTHENTICATION.md`
**Route Order Fix**: Express routing order documented & fixed
### Known Issues (Resolved)
**Express Route Order**: Consent router now mounted before admin router
**Test Permissions**: Tests use `/tmp/` for uploads
**SQLite Async**: Connection properly promisified
---
## ✅ Acceptance Criteria
- [ ] Feature Branch mit Integration existiert (`feature/autogen-openapi`).
- [ ] `npm run dev` zeigt Swagger UI unter `/api/docs` mit generierter Spec.
- [ ] Alle standardmäßigen Routen sind in der Spec enthalten (Smoke Test).
- [ ] UploadEndpoints erkannt; falls requestBody fehlt, dokumentierter Hinweis in PR.
- [ ] Feature ist deaktiviert in `production`.
- [ ] Optional: `docs/openapi.json` kann per script erzeugt werden.
## ⏱️ Aufwandsschätzung (Final)
| Phase | Zeit | Status |
|-------|------|--------|
| MVP OpenAPI Generation | 2h | ✅ Done |
| API Restructuring | 8h | ✅ Done |
| Authentication System | 4h | ✅ Done |
| Test Suite | 6h | ✅ Done |
| Documentation | 2h | ✅ Done |
| **Total** | **22h** | **100%** |
---
## ⚠️ Risiken & Mitigations
- Risiko: Generator erkennt Upload requestBody nicht korrekt.
Mitigation: Manuelle HintBlock oder kleine postprocessing Schritte in `generate:openapi` Script.
## 🚀 Frontend Migration Guide
- Risiko: Spec generiert sensitive Pfade/fields (management tokens).
Mitigation: Filter/Transformation im ExportScript oder nur generiertes file in CI/Review; Swagger UI devonly.
**Required Changes:**
1. **Add Bearer Token**: All `/api/admin/*` calls need `Authorization: Bearer <token>` header
2. **Verify Paths**: Check against `routeMappings.js` (consent: `/api/admin/groups/by-consent`)
3. **Handle 403**: Add error handling for missing authentication
4. **Environment**: Add `REACT_APP_ADMIN_API_KEY` to `.env`
**See `AUTHENTICATION.md` for complete setup guide**
---
## ✅ Next Steps (konkret)
1. Push Branch `feature/autogen-openapi` remote (falls noch nicht geschehen).
2. Ich implementiere MVP: installiere Generator, führe einmaligen Lauf aus, mounte Swagger UI und liefere `docs/openapi.json` und eine Liste der Endpunkte, die Hints brauchen.
3. PR mit Implementation + Docs.
4. Review und Nachbesserung des Specs (Uploads/Schemas).
**Erstellt am:** 16. November 2025
````
**Erstellt:** 16. November 2025
**Aktualisiert:** 16. November 2025
**Status:** ✅ Production Ready

294
frontend/MIGRATION-GUIDE.md Normal file
View File

@ -0,0 +1,294 @@
# Frontend Migration Guide - Admin API Authentication
**Datum:** 16. November 2025
**Betrifft:** Alle Admin-API-Aufrufe im Frontend
**Status:** ⚠️ Aktion erforderlich
---
## 🔒 Was hat sich geändert?
Alle Admin-Endpoints (`/api/admin/*`) benötigen jetzt **Bearer Token Authentication**.
### Betroffene Endpoints
- `/api/admin/groups` - Gruppen auflisten
- `/api/admin/groups/:id` - Gruppe abrufen
- `/api/admin/groups/:id/approve` - Gruppe genehmigen
- `/api/admin/groups/:id` - Gruppe löschen
- `/api/admin/groups/:id/images/:imageId` - Bild löschen
- `/api/admin/groups/by-consent` - Nach Consent filtern
- `/api/admin/consents/export` - Consent-Export
- `/api/admin/social-media/platforms` - Plattformen auflisten
- `/api/admin/reorder/:groupId/images` - Bilder neu anordnen
- `/api/admin/deletion-log` - Deletion Log
- `/api/admin/cleanup/*` - Cleanup-Funktionen
- `/api/admin/rate-limiter/stats` - Rate-Limiter-Statistiken
- `/api/admin/management-audit` - Audit-Log
**System-Endpoints:**
- `/api/system/migration/migrate` - Migration ausführen
- `/api/system/migration/rollback` - Migration zurückrollen
---
## 📝 Erforderliche Änderungen
### 1. Environment Variable hinzufügen
```bash
# frontend/.env oder frontend/.env.local
REACT_APP_ADMIN_API_KEY=your-secure-admin-token-here
```
**Token generieren:**
```bash
# Linux/Mac:
openssl rand -hex 32
# Node.js:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
**⚠️ Wichtig**: Gleiches Token in Backend `.env` als `ADMIN_API_KEY` eintragen!
### 2. API-Aufrufe anpassen
#### Vorher (ohne Auth):
```javascript
const response = await fetch('/api/admin/groups');
```
#### Nachher (mit Bearer Token):
```javascript
const response = await fetch('/api/admin/groups', {
headers: {
'Authorization': `Bearer ${process.env.REACT_APP_ADMIN_API_KEY}`,
'Content-Type': 'application/json'
}
});
```
### 3. Zentrale API-Helper-Funktion erstellen
**Empfohlen**: Erstelle eine zentrale Funktion für alle Admin-API-Calls:
```javascript
// src/services/adminApiService.js
const ADMIN_API_KEY = process.env.REACT_APP_ADMIN_API_KEY;
export const adminFetch = async (url, options = {}) => {
const defaultHeaders = {
'Authorization': `Bearer ${ADMIN_API_KEY}`,
'Content-Type': 'application/json'
};
const response = await fetch(url, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
if (response.status === 403) {
throw new Error('Authentication failed - Invalid or missing admin token');
}
return response;
};
// Verwendung:
import { adminFetch } from './services/adminApiService';
const response = await adminFetch('/api/admin/groups');
const data = await response.json();
```
### 4. Error Handling erweitern
```javascript
try {
const response = await adminFetch('/api/admin/groups');
if (response.status === 403) {
// Auth fehlt oder ungültig
console.error('Admin authentication required');
// Redirect zu Login oder Fehlermeldung anzeigen
}
if (response.status === 429) {
// Rate Limit überschritten
console.error('Too many requests');
}
const data = await response.json();
// ...
} catch (error) {
console.error('Admin API error:', error);
}
```
---
## 🔍 Betroffene Dateien finden
Such nach allen Admin-API-Aufrufen:
```bash
cd frontend/src
# Alle Admin-API-Calls finden:
grep -r "/api/admin" --include="*.js" --include="*.jsx"
# Alle Fetch-Calls in spezifischen Komponenten:
grep -r "fetch.*admin" Components/Pages/
```
**Wahrscheinlich betroffene Komponenten:**
- `Components/Pages/ModerationGroupsPage.js`
- `Components/Pages/ModerationGroupImagesPage.js`
- `Components/ComponentUtils/ConsentManager.js` (wenn im moderate-Modus)
- `services/reorderService.js` (Admin-Reorder)
- Jede andere Komponente, die Admin-Funktionen aufruft
---
## ✅ Checkliste
- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefügt
- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert
- [ ] Zentrale `adminFetch` Funktion erstellt
- [ ] Alle Admin-API-Calls gefunden (grep)
- [ ] Authorization Header zu allen Admin-Calls hinzugefügt
- [ ] 403 Error Handling implementiert
- [ ] Frontend lokal getestet
- [ ] Production `.env` aktualisiert
---
## 🧪 Testing
### Lokales Testing
1. Backend mit Admin-Key starten:
```bash
cd backend
echo "ADMIN_API_KEY=test-key-12345" >> .env
npm run dev
```
2. Frontend mit Admin-Key starten:
```bash
cd frontend
echo "REACT_APP_ADMIN_API_KEY=test-key-12345" >> .env.local
npm start
```
3. Moderation-Seite öffnen und Admin-Funktionen testen
### Test-Fälle
- ✅ Admin-Funktionen funktionieren mit gültigem Token
- ✅ 403 Error bei fehlendem/falschem Token
- ✅ Consent-Export funktioniert
- ✅ Gruppen löschen funktioniert
- ✅ Bilder neu anordnen funktioniert
---
## 📚 Weitere Dokumentation
- **Backend Auth-Doku**: `AUTHENTICATION.md`
- **API Route-Übersicht**: `backend/src/routes/README.md`
- **Route-Konfiguration**: `backend/src/routes/routeMappings.js`
- **OpenAPI Spec**: `backend/docs/openapi.json`
- **Swagger UI**: http://localhost:5001/api/docs (dev only)
---
## 🆘 Troubleshooting
### Problem: "403 Forbidden" Fehler
**Ursachen:**
1. `REACT_APP_ADMIN_API_KEY` nicht gesetzt
2. Token falsch konfiguriert (Frontend ≠ Backend)
3. Token enthält Leerzeichen/Zeilenumbrüche
**Lösung:**
```bash
# Frontend .env prüfen:
cat frontend/.env | grep ADMIN_API_KEY
# Backend .env prüfen:
cat backend/.env | grep ADMIN_API_KEY
# Beide müssen identisch sein!
```
### Problem: "ADMIN_API_KEY not configured" (500 Error)
**Ursache:** Backend hat `ADMIN_API_KEY` nicht in `.env`
**Lösung:**
```bash
cd backend
echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env
```
### Problem: Token wird nicht gesendet
**Prüfen in Browser DevTools:**
1. Network Tab öffnen
2. Admin-API-Request auswählen
3. "Headers" Tab prüfen
4. Sollte enthalten: `Authorization: Bearer <token>`
### Problem: CORS-Fehler
**Ursache:** Backend CORS-Middleware blockiert Authorization-Header
**Lösung:** Bereits implementiert in `backend/src/middlewares/cors.js`:
```javascript
allowedHeaders: ['Content-Type', 'Authorization']
```
---
## 🚀 Deployment
### Production Checklist
- [ ] Sicheren Admin-Token generiert (min. 32 Bytes hex)
- [ ] Token in Backend `.env` als `ADMIN_API_KEY`
- [ ] Token in Frontend Build-Environment als `REACT_APP_ADMIN_API_KEY`
- [ ] Token NICHT in Git committed (in `.gitignore`)
- [ ] HTTPS verwendet (Token über unverschlüsselte HTTP ist unsicher)
- [ ] Token-Rotation-Prozess dokumentiert
- [ ] Backup des Tokens an sicherem Ort gespeichert
### Docker Deployment
```yaml
# docker-compose.yml
services:
backend:
environment:
- ADMIN_API_KEY=${ADMIN_API_KEY}
frontend:
environment:
- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}
```
```bash
# .env (nicht in Git!)
ADMIN_API_KEY=your-production-token-here
```
---
**Fragen?** Siehe `AUTHENTICATION.md` für detaillierte Backend-Dokumentation.
**Status der Backend-Changes:** ✅ Vollständig implementiert und getestet (45/45 Tests passing)

View File

@ -145,7 +145,8 @@ function ManagementPortalPage() {
try {
const imageIds = newOrder.map(img => img.id);
const response = await fetch(`/api/groups/${group.groupId}/reorder`, {
// Use token-based management API
const response = await fetch(`/api/manage/${token}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageIds: imageIds })