Merge feature/autogen-openapi: Complete API restructuring with tests

 Completed Features:
- Comprehensive test suite (45 tests, 100% passing)
- Admin API authentication (Bearer Token)
- Automatic OpenAPI generation from route mappings
- Complete API documentation
- Frontend migration guide

📊 Changes:
- Backend: Production ready with 26% test coverage
- Frontend: Migration required (ALL routes changed)
- Documentation: Complete suite for developers

See CHANGELOG.md and frontend/MIGRATION-GUIDE.md for details.
This commit is contained in:
Matthias Lotz 2025-11-16 18:26:26 +01:00
commit 25324cb91f
41 changed files with 6397 additions and 429 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 ## [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) ### 🎨 Modular UI Architecture (November 15, 2025)
#### Features #### Features

View File

@ -1,5 +1,24 @@
# Development Setup # Development Setup
## ⚠️ Wichtige Hinweise für Frontend-Entwickler
### 🔴 BREAKING CHANGES - API-Umstrukturierung (November 2025)
Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen:
- **Authentication**: Alle Admin-Endpoints benötigen jetzt Bearer Token
- **Route-Struktur**: Einige Pfade haben sich geändert (Single Source of Truth: `routeMappings.js`)
- **Error Handling**: Neue HTTP-Status-Codes (403 für Auth-Fehler)
**📖 Siehe:**
- **`frontend/MIGRATION-GUIDE.md`** - Detaillierte Migrations-Anleitung für Frontend
- **`backend/src/routes/README.md`** - Vollständige API-Route-Dokumentation
- **`AUTHENTICATION.md`** - Auth-System-Setup und Verwendung
**Geschätzter Migrations-Aufwand**: 2-3 Stunden
---
## Schnellstart ## Schnellstart
### Starten (Development Environment) ### Starten (Development Environment)
@ -15,6 +34,7 @@ docker compose -f docker/dev/docker-compose.yml up -d
### Zugriff ### Zugriff
- **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv) - **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv)
- **Backend**: http://localhost:5001 (API) - **Backend**: http://localhost:5001 (API)
- **API Documentation**: http://localhost:5001/api/docs (Swagger UI)
- **Slideshow**: http://localhost:3000/slideshow - **Slideshow**: http://localhost:3000/slideshow
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth) - **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
@ -30,6 +50,89 @@ docker compose -f docker/dev/docker-compose.yml logs -f frontend-dev
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
``` ```
## API-Entwicklung
### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich
**Massive API-Änderungen im November 2025:**
- Bearer Token Authentication für alle Admin-Endpoints
- Route-Pfade umstrukturiert (siehe `routeMappings.js`)
- Neue Error-Response-Formate
**📖 Frontend Migration Guide**: `frontend/MIGRATION-GUIDE.md`
### 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 ## Entwicklung
### Frontend-Entwicklung ### Frontend-Entwicklung
@ -50,9 +153,11 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
- Environment: `NODE_ENV=development` - Environment: `NODE_ENV=development`
**Wichtige Module:** **Wichtige Module:**
- `routes/routeMappings.js` - Single Source of Truth für Route-Konfiguration
- `repositories/GroupRepository.js` - Consent-Management & CRUD - `repositories/GroupRepository.js` - Consent-Management & CRUD
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung - `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
- `routes/batchUpload.js` - Upload mit Consent-Validierung - `routes/batchUpload.js` - Upload mit Consent-Validierung
- `middlewares/auth.js` - Admin Authentication (Bearer Token)
- `database/DatabaseManager.js` - Automatische Migrationen - `database/DatabaseManager.js` - Automatische Migrationen
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik - `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik
@ -95,6 +200,63 @@ docker compose -f docker/dev/docker-compose.yml logs backend-dev | grep -i migra
## Testing ## 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 ### Consent-System testen
```bash ```bash
# 1. Upload mit und ohne Workshop-Consent # 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. 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) ### 🆕 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): - **🔐 Social Media Consent Management** (Phase 1 Complete - Nov 9-10):
- GDPR-compliant consent system for image usage - GDPR-compliant consent system for image usage
- Mandatory workshop display consent (no upload without approval) - 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) ### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication) - **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**: - **Features**:
- Review pending image groups before public display - Review pending image groups before public display
- Visual countdown showing days until automatic deletion (7 days for unapproved groups) - 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 for the backend server
PORT=5000 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) # Database settings (if needed in future)
# DB_HOST=localhost # DB_HOST=localhost
# DB_PORT=3306 # DB_PORT=3306

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": "npm run dev --prefix ../frontend",
"client-build": "cd ../frontend && npm run build && serve -s build -l 80", "client-build": "cd ../frontend && npm run build && serve -s build -l 80",
"dev": "concurrently \"npm run server\" \"npm run client\"", "dev": "concurrently \"npm run server\" \"npm run client\"",
"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": [], "keywords": [],
"author": "", "author": "",
@ -27,7 +33,13 @@
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@redocly/cli": "^2.11.1",
"@stoplight/prism-cli": "^5.14.2",
"concurrently": "^6.0.0", "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 // Filesystem directory (relative to backend/src) where uploaded images will be stored
// Use path.join(__dirname, '..', UPLOAD_FS_DIR, fileName) in code // 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 // Filesystem directory (relative to backend/src) where preview images will be stored
// Use path.join(__dirname, '..', PREVIEW_FS_DIR, fileName) in code // 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 // Preview generation configuration
const PREVIEW_CONFIG = { const PREVIEW_CONFIG = {
@ -29,4 +23,4 @@ const time = {
WEEK_1: 604800000 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,27 +5,37 @@ const fs = require('fs');
class DatabaseManager { class DatabaseManager {
constructor() { constructor() {
this.db = null; this.db = null;
// Place database file under data/db // Use in-memory database for tests, file-based for production
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db'); if (process.env.NODE_ENV === 'test') {
this.dbPath = ':memory:';
} else {
// Place database file under data/db
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
}
this.schemaPath = path.join(__dirname, 'schema.sql'); this.schemaPath = path.join(__dirname, 'schema.sql');
} }
async initialize() { async initialize() {
try { try {
// Stelle sicher, dass das data-Verzeichnis existiert // Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
const dataDir = path.dirname(this.dbPath); if (this.dbPath !== ':memory:') {
if (!fs.existsSync(dataDir)) { const dataDir = path.dirname(this.dbPath);
fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
} }
// Öffne Datenbankverbindung // Öffne Datenbankverbindung (promisify for async/await)
this.db = new sqlite3.Database(this.dbPath, (err) => { await new Promise((resolve, reject) => {
if (err) { this.db = new sqlite3.Database(this.dbPath, (err) => {
console.error('Fehler beim Öffnen der Datenbank:', err.message); if (err) {
throw err; console.error('Fehler beim Öffnen der Datenbank:', err.message);
} else { reject(err);
console.log('✓ SQLite Datenbank verbunden:', this.dbPath); } else {
} console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
resolve();
}
});
}); });
// Aktiviere Foreign Keys // Aktiviere Foreign Keys
@ -37,8 +47,10 @@ class DatabaseManager {
// Run database migrations (automatic on startup) // Run database migrations (automatic on startup)
await this.runMigrations(); await this.runMigrations();
// Generate missing previews for existing images // Generate missing previews for existing images (skip in test mode)
await this.generateMissingPreviews(); if (process.env.NODE_ENV !== 'test') {
await this.generateMissingPreviews();
}
console.log('✓ Datenbank erfolgreich initialisiert'); console.log('✓ Datenbank erfolgreich initialisiert');
} catch (error) { } 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 router = express.Router();
const DeletionLogRepository = require('../repositories/DeletionLogRepository'); const DeletionLogRepository = require('../repositories/DeletionLogRepository');
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository'); const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
const GroupRepository = require('../repositories/GroupRepository');
const GroupCleanupService = require('../services/GroupCleanupService'); const GroupCleanupService = require('../services/GroupCleanupService');
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter'); const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
const { requireAdminAuth } = require('../middlewares/auth');
// GroupCleanupService ist bereits eine Instanz, keine Klasse // GroupCleanupService ist bereits eine Instanz, keine Klasse
const cleanupService = GroupCleanupService; 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) => { 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 { try {
const limit = parseInt(req.query.limit) || 10; 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) => { 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 { try {
const deletions = await DeletionLogRepository.getAllDeletions(); 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) => { 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 { try {
const stats = await DeletionLogRepository.getDeletionStatistics(); 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) => { 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 { try {
console.log('[Admin API] Manual cleanup triggered'); console.log('[Admin API] Manual cleanup triggered');
const result = await cleanupService.performScheduledCleanup(); 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) => { 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 { try {
const groups = await cleanupService.findGroupsForDeletion(); 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) => { 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 { try {
const stats = getRateLimiterStats(); 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) => { 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 { try {
const limit = parseInt(req.query.limit) || 100; 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) => { 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 { try {
const stats = await ManagementAuditLogRepository.getStatistics(); 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) => { 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 { try {
const { groupId } = req.params; const { groupId } = req.params;
const logs = await ManagementAuditLogRepository.getLogsByGroupId(groupId); 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; module.exports = router;

View File

@ -2,7 +2,6 @@ const generateId = require("shortid");
const express = require('express'); const express = require('express');
const { Router } = require('express'); const { Router } = require('express');
const path = require('path'); const path = require('path');
const { endpoints } = require('../constants');
const UploadGroup = require('../models/uploadGroup'); const UploadGroup = require('../models/uploadGroup');
const groupRepository = require('../repositories/GroupRepository'); const groupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
@ -10,8 +9,81 @@ const ImagePreviewService = require('../services/ImagePreviewService');
const router = Router(); 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 // Batch-Upload für mehrere Bilder
router.post(endpoints.UPLOAD_BATCH, async (req, res) => { router.post('/upload/batch', async (req, res) => {
try { try {
// Überprüfe ob Dateien hochgeladen wurden // Überprüfe ob Dateien hochgeladen wurden
if (!req.files || !req.files.images) { if (!req.files || !req.files.images) {

View File

@ -9,16 +9,35 @@ const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository'); const GroupRepository = require('../repositories/GroupRepository');
const SocialMediaRepository = require('../repositories/SocialMediaRepository'); const SocialMediaRepository = require('../repositories/SocialMediaRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth');
// Schütze alle Consent-Routes mit Admin-Auth
router.use(requireAdminAuth);
// ============================================================================ // ============================================================================
// Social Media Platforms // Social Media Platforms
// ============================================================================ // ============================================================================
/** /**
* GET /api/social-media/platforms * GET /social-media/platforms
* Liste aller aktiven Social Media Plattformen * 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 { try {
const socialMediaRepo = new SocialMediaRepository(dbManager); const socialMediaRepo = new SocialMediaRepository(dbManager);
const platforms = await socialMediaRepo.getActivePlatforms(); 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 * Speichere oder aktualisiere Consents für eine Gruppe
* *
* Body: { * Body: {
@ -46,7 +65,7 @@ router.get('/api/social-media/platforms', async (req, res) => {
* socialMediaConsents: [{ platformId: number, consented: boolean }] * socialMediaConsents: [{ platformId: number, consented: boolean }]
* } * }
*/ */
router.post('/api/groups/:groupId/consents', async (req, res) => { router.post('/groups/:groupId/consents', async (req, res) => {
try { try {
const { groupId } = req.params; const { groupId } = req.params;
const { workshopConsent, socialMediaConsents } = req.body; 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 * 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 { try {
const { groupId } = req.params; 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 * Filtere Gruppen nach Consent-Status
* *
* Query params: * Query params:
@ -156,7 +205,43 @@ router.get('/api/groups/:groupId/consents', async (req, res) => {
* - platformId: number * - platformId: number
* - platformConsent: boolean * - 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 { try {
const filters = {}; 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 * Export Consent-Daten für rechtliche Dokumentation
* *
* Query params: * Query params:
@ -207,7 +292,54 @@ router.get('/api/admin/groups/by-consent', async (req, res) => {
* - year: number (optional filter) * - year: number (optional filter)
* - approved: boolean (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 { try {
const format = req.query.format || 'json'; const format = req.query.format || 'json';
const filters = {}; const filters = {};

View File

@ -1,10 +1,36 @@
const { Router } = require('express'); const { Router } = require('express');
const { endpoints, UPLOAD_FS_DIR } = require('../constants'); const { UPLOAD_FS_DIR } = require('../constants');
const path = require('path'); const path = require('path');
const router = Router(); 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); const filePath = path.join(__dirname, '..', UPLOAD_FS_DIR, req.params.id);
res.download(filePath); res.download(filePath);
}); });

View File

@ -1,12 +1,57 @@
const { Router } = require('express'); const { Router } = require('express');
const { endpoints } = require('../constants');
const GroupRepository = require('../repositories/GroupRepository'); const GroupRepository = require('../repositories/GroupRepository');
const MigrationService = require('../services/MigrationService'); const MigrationService = require('../services/MigrationService');
const router = Router(); const router = Router();
/**
* @swagger
* /groups:
* get:
* tags: [Groups]
* summary: Get all approved groups with images
* description: Returns all approved groups with their images for public slideshow display. Automatically triggers migration if needed.
* responses:
* 200:
* description: List of approved groups
* content:
* application/json:
* schema:
* type: object
* properties:
* groups:
* type: array
* items:
* type: object
* properties:
* groupId:
* type: string
* example: "cTV24Yn-a"
* year:
* type: integer
* example: 2024
* title:
* type: string
* example: "Familie Mueller"
* description:
* type: string
* name:
* type: string
* approved:
* type: boolean
* example: true
* images:
* type: array
* items:
* type: object
* totalCount:
* type: integer
* example: 73
* 500:
* description: Server error
*/
// Alle Gruppen abrufen (für Slideshow mit vollständigen Bilddaten) // Alle Gruppen abrufen (für Slideshow mit vollständigen Bilddaten)
router.get(endpoints.GET_ALL_GROUPS, async (req, res) => { router.get('/groups', async (req, res) => {
try { try {
// Auto-Migration beim ersten Zugriff // Auto-Migration beim ersten Zugriff
const migrationStatus = await MigrationService.getMigrationStatus(); 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) => { * @swagger
try { * /groups/{groupId}:
const { workshopOnly, platform } = req.query; * get:
* tags: [Groups]
// Hole alle Gruppen mit vollständigen Infos (inkl. Bilder) * summary: Get a specific approved group by ID
let allGroups = await GroupRepository.getAllGroupsWithModerationInfo(); * description: Returns details of a single approved group with all its images
* parameters:
// Füge Consent-Daten für jede Gruppe hinzu * - in: path
const groupsWithConsents = await Promise.all( * name: groupId
allGroups.map(async (group) => { * required: true
const consents = await GroupRepository.getSocialMediaConsentsForGroup(group.groupId); * schema:
return { * type: string
...group, * example: "cTV24Yn-a"
socialMediaConsents: consents * description: Unique identifier of the group
}; * responses:
}) * 200:
); * description: Group details
* content:
// Jetzt filtern wir basierend auf den Query-Parametern * application/json:
let filteredGroups = groupsWithConsents; * schema:
* type: object
if (workshopOnly === 'true') { * properties:
// Filter: Nur Gruppen MIT Werkstatt-Consent aber OHNE zugestimmte Social Media Consents * groupId:
filteredGroups = groupsWithConsents.filter(group => { * type: string
// Muss Werkstatt-Consent haben * year:
if (!group.display_in_workshop) return false; * type: integer
* title:
// Darf KEINE zugestimmten Social Media Consents haben * type: string
const hasConsentedSocialMedia = group.socialMediaConsents && * description:
group.socialMediaConsents.some(consent => consent.consented === 1 || consent.consented === true); * type: string
* name:
return !hasConsentedSocialMedia; * type: string
}); * approved:
} else if (platform) { * type: boolean
// Filter: Gruppen mit bestimmter Social Media Platform (unabhängig vom Werkstatt-Consent) * images:
filteredGroups = groupsWithConsents.filter(group => * type: array
group.socialMediaConsents && * items:
group.socialMediaConsents.some(consent => * type: object
consent.platform_name === platform && (consent.consented === 1 || consent.consented === true) * 404:
) * description: Group not found
); * 500:
} * description: Server error
// else: Kein Filter - zeige ALLE Gruppen (nicht filtern) */
// Einzelne Gruppe abrufen (nur freigegebene)
res.json({ router.get('/groups/:groupId', async (req, res) => {
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) => {
try { try {
const { groupId } = req.params; const { groupId } = req.params;
const group = await GroupRepository.getGroupById(groupId); const group = await GroupRepository.getGroupById(groupId);
@ -139,243 +143,4 @@ router.get(endpoints.GET_GROUP, async (req, res) => {
} }
}); });
// Gruppe freigeben/genehmigen module.exports = router;
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 consentRouter = require('./consent');
const managementRouter = require('./management'); 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) => { const renderRoutes = (app) => {
[uploadRouter, downloadRouter, batchUploadRouter, groupsRouter, migrationRouter, consentRouter].forEach(router => app.use('/', router)); routeMappingsConfig.forEach(({ router, prefix }) => {
app.use('/groups', reorderRouter); app.use(prefix, routerMap[router]);
app.use('/api/admin', adminRouter); });
app.use('/api/manage', managementRouter);
}; };
module.exports = { renderRoutes }; module.exports = { renderRoutes };

View File

@ -25,6 +25,35 @@ const validateToken = (token) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.get('/:token', async (req, res) => { 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 { try {
const { token } = req.params; const { token } = req.params;
@ -85,6 +114,44 @@ router.get('/:token', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.put('/:token/consents', async (req, res) => { 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 { try {
const { token } = req.params; const { token } = req.params;
const { consentType, action, platformId } = req.body; const { consentType, action, platformId } = req.body;
@ -229,6 +296,42 @@ router.put('/:token/consents', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.put('/:token/images/descriptions', async (req, res) => { 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 { try {
const { token } = req.params; const { token } = req.params;
const { descriptions } = req.body; const { descriptions } = req.body;
@ -328,6 +431,45 @@ router.put('/:token/images/descriptions', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.put('/:token/metadata', async (req, res) => { 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 { try {
const { token } = req.params; const { token } = req.params;
const { title, description, name } = req.body; const { title, description, name } = req.body;
@ -425,6 +567,43 @@ router.put('/:token/metadata', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.post('/:token/images', async (req, res) => { 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 { try {
const { token } = req.params; const { token } = req.params;
@ -581,6 +760,43 @@ router.post('/:token/images', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.delete('/:token/images/:imageId', async (req, res) => { 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 { try {
const { token, imageId } = req.params; const { token, imageId } = req.params;
@ -694,6 +910,33 @@ router.delete('/:token/images/:imageId', async (req, res) => {
* @throws {500} Server error * @throws {500} Server error
*/ */
router.delete('/:token', async (req, res) => { 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 { try {
const { token } = req.params; 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; module.exports = router;

View File

@ -2,11 +2,25 @@ const express = require('express');
const { Router } = require('express'); const { Router } = require('express');
const MigrationService = require('../services/MigrationService'); const MigrationService = require('../services/MigrationService');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { requireAdminAuth } = require('../middlewares/auth');
const router = Router(); const router = Router();
// Migration Status abrufen router.get('/status', async (req, res) => {
router.get('/migration/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 { try {
const status = await MigrationService.getMigrationStatus(); const status = await MigrationService.getMigrationStatus();
res.json(status); res.json(status);
@ -20,8 +34,25 @@ router.get('/migration/status', async (req, res) => {
} }
}); });
// Manuelle Migration starten // Protect dangerous migration operations with admin auth
router.post('/migration/migrate', async (req, res) => { 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 { try {
const result = await MigrationService.migrateJsonToSqlite(); const result = await MigrationService.migrateJsonToSqlite();
res.json(result); res.json(result);
@ -35,8 +66,23 @@ router.post('/migration/migrate', async (req, res) => {
} }
}); });
// Rollback zu JSON (Notfall) router.post('/rollback', requireAdminAuth, async (req, res) => {
router.post('/migration/rollback', 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 { try {
const result = await MigrationService.rollbackToJson(); const result = await MigrationService.rollbackToJson();
res.json(result); res.json(result);
@ -50,8 +96,31 @@ router.post('/migration/rollback', async (req, res) => {
} }
}); });
// Datenbank Health Check router.get('/health', async (req, res) => {
router.get('/migration/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 { try {
const isHealthy = await dbManager.healthCheck(); const isHealthy = await dbManager.healthCheck();
res.json({ res.json({

View File

@ -3,24 +3,66 @@ const router = express.Router();
const GroupRepository = require('../repositories/GroupRepository'); const GroupRepository = require('../repositories/GroupRepository');
/** /**
* PUT /api/groups/:groupId/reorder * @swagger
* Reorder images within a group * /{groupId}/reorder:
* * put:
* Request Body: * tags: [Admin]
* { * summary: Reorder images within a group
* "imageIds": [123, 456, 789] // Array of image IDs in new order * 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
* Response: * name: groupId
* { * required: true
* "success": true, * schema:
* "message": "Image order updated successfully", * type: string
* "data": { * example: "cTV24Yn-a"
* "groupId": "abc123", * description: Unique identifier of the group
* "updatedImages": 3, * requestBody:
* "newOrder": [123, 456, 789] * 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) => { router.put('/:groupId/reorder', async (req, res) => {
try { 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 generateId = require("shortid");
const express = require('express'); const express = require('express');
const { Router } = 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 path = require('path');
const ImagePreviewService = require('../services/ImagePreviewService'); const ImagePreviewService = require('../services/ImagePreviewService');
const groupRepository = require('../repositories/GroupRepository'); const groupRepository = require('../repositories/GroupRepository');
@ -10,15 +10,49 @@ const fs = require('fs');
const router = Router(); const router = Router();
// Serve uploaded images via URL /upload but store files under data/images // 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 // 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) => { router.post('/upload', async (req, res) => {
if(req.files === null){ /*
#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'); 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; const file = req.files.file;
@ -28,7 +62,10 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => {
fileEnding = fileEnding[fileEnding.length - 1] fileEnding = fileEnding[fileEnding.length - 1]
fileName = generateId() + '.' + fileEnding 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 { try {
// Save the uploaded file // Save the uploaded file
@ -72,11 +109,11 @@ router.post(endpoints.UPLOAD_FILE, async (req, res) => {
images: [{ images: [{
fileName: fileName, fileName: fileName,
originalName: file.name, originalName: file.name,
filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`, filePath: `/upload/${fileName}`,
uploadOrder: 1, uploadOrder: 1,
fileSize: fileSize, fileSize: fileSize,
mimeType: file.mimetype, 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 // Return immediately with file path
res.json({ res.json({
filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`, filePath: `/upload/${fileName}`,
fileName: fileName, fileName: fileName,
groupId: groupId, groupId: groupId,
groupName: groupName groupName: groupName

View File

@ -3,6 +3,29 @@ const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager'); const dbManager = require('./database/DatabaseManager');
const SchedulerService = require('./services/SchedulerService'); const SchedulerService = require('./services/SchedulerService');
// 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 { class Server {
_port; _port;
_app; _app;
@ -22,6 +45,12 @@ class Server {
// Starte Express Server // Starte Express Server
initiateResources(this._app); initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload')); 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, () => { this._app.listen(this._port, () => {
console.log(`✅ Server läuft auf Port ${this._port}`); console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`); console.log(`📊 SQLite Datenbank aktiv`);
@ -34,6 +63,23 @@ class Server {
process.exit(1); 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; module.exports = Server;

View File

@ -7,6 +7,12 @@ class SchedulerService {
} }
start() { 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...'); console.log('[Scheduler] Starting scheduled tasks...');
// Cleanup-Job: Jeden Tag um 10:00 Uhr // 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 removeImages = require('./remove-images');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { endpoints, UPLOAD_FS_DIR } = require('../constants'); const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const initiateResources = (app) => { const initiateResources = (app) => {
@ -11,12 +11,23 @@ const initiateResources = (app) => {
renderRoutes(app); renderRoutes(app);
// Ensure upload images directory exists: backend/src/../data/images // Ensure upload images directory exists
const imagesDir = path.join(__dirname, '..', UPLOAD_FS_DIR); // 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)){ if (!fs.existsSync(imagesDir)){
fs.mkdirSync(imagesDir, { recursive: true }); 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 // Ensure db directory exists: backend/src/../data/db
const dbDir = path.join(__dirname, '..', 'data', 'db'); const dbDir = path.join(__dirname, '..', 'data', 'db');
if (!fs.existsSync(dbDir)){ if (!fs.existsSync(dbDir)){

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

@ -0,0 +1,195 @@
# 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
## 🎯 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
---
## 📊 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
```
---
## 🔧 Technische Implementierung
### 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
---
## 📚 Für KI-Nutzung
### API-Hierarchie verstehen
```
/api/* ← Alle API-Endpoints
├─ /upload, /groups ← Öffentlich
├─ /manage/:token/* ← Token-basiert
├─ /admin/* ← Geschützt
└─ /system/* ← Intern
```
### Neue Route hinzufügen
```bash
# 1. Route in passender Datei hinzufügen (z.B. admin.js)
router.get('/new-endpoint', ...)
# 2. In routeMappings.js registrieren (falls neue Datei)
{ router: 'newRoute', prefix: '/api/admin', file: 'newRoute.js' }
# 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
```
---
## ✅ Implementierungsstatus (November 16, 2025)
### 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
---
## ⏱️ 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%** |
---
## 🚀 Frontend Migration Guide
**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**
---
**Erstellt:** 16. November 2025
**Aktualisiert:** 16. November 2025
**Status:** ✅ Production Ready

View File

@ -0,0 +1,55 @@
````markdown
# Feature Request: Autogenerierte OpenAPI / Swagger Spec
**Kurzbeschreibung**: Automatische Erzeugung einer OpenAPI (Swagger) Spec aus dem ExpressBackend (devonly), so dass neue Routen sofort und ohne manuelles Nacharbeiten in der APIDokumentation erscheinen.
**Motivation / Nutzen**:
- Single source of truth: Routen im Code sind die einzige Quelle; keine manuelle openapi.json Pflege.
- Entwicklerfreundlich: Neue Route → Doku beim nächsten Serverstart sichtbar.
- Schnelle Übersicht für QA und APIReviewer via Swagger UI.
- Reduziert Drift zwischen Implementierung und Dokumentation.
---
## Aktueller Stand
- Backend ist Expressbasiert, Routen sind statisch in `backend/src/routes` definiert.
- `express-fileupload` wird als Middleware verwendet.
- Keine automatische OpenAPI Spec derzeit vorhanden.
---
## Anforderungen an das Feature
1. Beim lokalen DevStart soll eine OpenAPI Spec erzeugt werden (z. B. mit `swagger-autogen` oder `express-oas-generator`).
2. Eine Swagger UI (nur in Dev) soll unter `/api/docs` erreichbar sein und die erzeugte Spec anzeigen.
3. Automatisch erkannte Endpunkte müssen sichtbar sein; für komplexe Fälle (multipart Uploads) sollen einfache Hints / Overrides möglich sein.
4. Keine Breaking Changes am ProduktionsStartverhalten: Autogen nur in `NODE_ENV !== 'production'` oder per optin env var.
5. Erzeugte Spec soll ins Repo (z. B. `docs/openapi.json`) optional geschrieben werden können (für CI/Review).
---
## Minimaler Scope (MVP)
- Devonly Integration: Generator installiert und beim Start einmal ausgeführt.
- Swagger UI unter `/api/docs` mit generierter Spec.
- Kurze Anleitung im `README.dev.md` wie man die Doku lokal öffnet.
---
## Akzeptanzkriterien
- [ ] Swagger UI zeigt alle standardmäßig erkannten Endpoints an.
- [ ] UploadEndpoints erscheinen (Pfad erkannt). Falls requestBody fehlt, ist ein klarer Hinweis dokumentiert.
- [ ] Feature ist deaktivierbar in `production`.
- [ ] Optionaler Export: `docs/openapi.json` kann per npm script erzeugt werden.
---
## Geschätzter Aufwand (MVP)
- Setup & smoke test: 12h
- Anpassungen für UploadHints + kleine Nacharbeiten: 12h
- Optionales Export/CI: +1h
---
**Erstellt am**: 16. November 2025
````

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

@ -0,0 +1,399 @@
# Frontend Migration Guide - API Umstrukturierung
**Datum:** 16. November 2025
**Betrifft:** ALLE API-Aufrufe im Frontend
**Status:** ⚠️ Aktion erforderlich - ALLE Routen prüfen!
---
## <20> BREAKING CHANGE: Konsistente `/api` Prefixes
**ALLE API-Routen haben sich geändert!**
### Vorher (inkonsistent):
```javascript
// Teils mit /api
fetch('/api/upload/batch')
fetch('/api/manage/xyz')
// Teils OHNE /api - FALSCH!
fetch('/groups/123')
fetch('/groups/123/approve')
fetch('/moderation/groups/123')
```
### Jetzt (konsistent):
```javascript
// ALLE Routen mit /api Prefix
fetch('/api/upload/batch')
fetch('/api/manage/xyz')
fetch('/api/groups/123') // Public
fetch('/api/admin/groups/123/approve') // Admin
fetch('/api/admin/groups/123') // Admin
```
---
## 🔒 Admin API Authentication
Alle Admin-Endpoints (`/api/admin/*` und `/api/system/*`) benötigen jetzt **Bearer Token Authentication**.
### Route-Hierarchie
1. **Public API**: `/api/*`
- Öffentlich zugänglich
- `/api/upload`, `/api/groups`, `/api/download`, etc.
2. **Management API**: `/api/manage/*`
- Token-basiert (UUID aus Upload-Response)
- Für Gruppenbesitzer
3. **Admin API**: `/api/admin/*` ⚠️ **BEARER TOKEN ERFORDERLICH**
- Moderation, Logs, Consents
- `/api/admin/groups`, `/api/admin/deletion-log`, etc.
4. **System API**: `/api/system/migration/*` ⚠️ **BEARER TOKEN ERFORDERLICH**
- Wartungsfunktionen
### Betroffene Admin-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. ALLE API-Routen prüfen und `/api` hinzufügen
**Schritt 1**: Finde alle API-Aufrufe im Frontend:
```bash
# Alle fetch/axios Aufrufe finden
grep -r "fetch\(" frontend/src/
grep -r "axios\." frontend/src/
```
**Schritt 2**: Prüfe jede Route und füge `/api` Prefix hinzu (falls fehlend):
```javascript
// ❌ FALSCH (alte Routen)
fetch('/groups/123')
fetch('/groups/123/approve')
fetch('/moderation/groups/123')
// ✅ RICHTIG (neue Routen)
fetch('/api/groups/123') // Public
fetch('/api/admin/groups/123/approve') // Admin (+ Bearer Token!)
fetch('/api/admin/groups/123') // Admin (+ Bearer Token!)
```
### 2. Environment Variable für Admin Token hinzufügen
```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!
### 3. API-Aufrufe für Admin-Endpoints 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
### Alle API-Calls prüfen (KRITISCH!)
```bash
cd frontend/src
# ALLE API-Calls finden (fetch + axios):
grep -rn "fetch(" --include="*.js" --include="*.jsx"
grep -rn "axios\." --include="*.js" --include="*.jsx"
# Spezifisch nach alten Routen OHNE /api suchen:
grep -rn "fetch('/groups" --include="*.js"
grep -rn "fetch('/moderation" --include="*.js"
# Admin-API-Calls finden:
grep -rn "/api/admin" --include="*.js" --include="*.jsx"
```
**Bekannte betroffene Dateien:**
### Routen ohne `/api` Prefix (MÜSSEN GEFIXT WERDEN):
- `Components/Pages/ModerationGroupsPage.js`
- ❌ `/groups/${groupId}/approve` → ✅ `/api/admin/groups/${groupId}/approve`
- ❌ `/groups/${groupId}` (DELETE) → ✅ `/api/admin/groups/${groupId}`
- ❌ `/api/social-media/platforms` → ✅ `/api/admin/social-media/platforms`
- `Components/Pages/ModerationGroupImagesPage.js`
- ❌ `/moderation/groups/${groupId}` → ✅ `/api/admin/groups/${groupId}`
- `Components/Pages/PublicGroupImagesPage.js`
- ❌ `/groups/${groupId}` → ✅ `/api/groups/${groupId}`
### Admin-Endpoints (benötigen Bearer Token):
- `Components/Pages/ModerationGroupsPage.js` - Alle Admin-Calls
- `Components/Pages/ModerationGroupImagesPage.js` - Gruppe laden + Bilder löschen
- `Components/ComponentUtils/DeletionLogSection.js` - Deletion Log
- `Components/ComponentUtils/ConsentManager.js` - Consent Export (wenn Admin)
- `services/reorderService.js` - Admin-Reorder (wenn vorhanden)
### Public/Management Endpoints (nur Pfad prüfen):
- `Utils/batchUpload.js` - Bereits korrekt (`/api/...`)
- `Components/Pages/ManagementPortalPage.js` - Bereits korrekt (`/api/manage/...`)
- `Utils/sendRequest.js` - Bereits korrekt (axios)
---
## ✅ Checkliste
### Phase 1: Route-Prefixes (ALLE Dateien)
- [ ] Alle `fetch()` und `axios` Calls gefunden (grep)
- [ ] Alle Routen ohne `/api` Prefix identifiziert
- [ ] `/api` Prefix zu Public-Routen hinzugefügt (`/api/groups`, `/api/upload`)
- [ ] Admin-Routen auf `/api/admin/*` geändert
- [ ] Management-Routen auf `/api/manage/*` geprüft (sollten schon korrekt sein)
### Phase 2: Admin Authentication
- [ ] `REACT_APP_ADMIN_API_KEY` in `.env` hinzugefügt
- [ ] Token im Backend als `ADMIN_API_KEY` konfiguriert
- [ ] Zentrale `adminFetch` Funktion erstellt
- [ ] Authorization Header zu ALLEN `/api/admin/*` Calls hinzugefügt
- [ ] Authorization Header zu `/api/system/*` Calls hinzugefügt (falls vorhanden)
- [ ] 403 Error Handling implementiert
### Phase 3: Testing & Deployment
- [ ] Frontend lokal getestet (alle Routen)
- [ ] Admin-Funktionen getestet (Approve, Delete, etc.)
- [ ] Public-Routen getestet (Gruppe laden, Upload)
- [ ] 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 { try {
const imageIds = newOrder.map(img => img.id); 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageIds: imageIds }) body: JSON.stringify({ imageIds: imageIds })