Compare commits
32 Commits
feature/se
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b13872c9 | |||
| 0d24a5e74c | |||
| 2acbc4e248 | |||
| 27d8c73b5f | |||
| 46198ddfdd | |||
| 6b603112de | |||
| dd71dcab44 | |||
| d76b4b2c9c | |||
| 489e2166bb | |||
| 8cceb8e9a3 | |||
| 62be18ecaa | |||
| 025578fa3d | |||
| 15833dec83 | |||
| 86ace42fca | |||
| b2386e7f11 | |||
| 52125397bf | |||
| aea21622f7 | |||
| bd10f6533e | |||
| bf26472ea3 | |||
| ec3d7ee4d0 | |||
| 8818d2987d | |||
| 40aa546498 | |||
| e4712f9e7e | |||
| e4a76a6b3d | |||
| 91d6d06687 | |||
| 215acaa67f | |||
| 25dda32c4e | |||
| 920a81e075 | |||
| e4ddd229b8 | |||
| 712b8477b9 | |||
| 7ac8a70260 | |||
| e48cf69b5d |
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -9,6 +9,11 @@ node_modules/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# Telegram credentials
|
||||||
|
scripts/.env.telegram
|
||||||
|
scripts/node_modules/
|
||||||
|
scripts/package-lock.json
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
158
CHANGELOG.md
158
CHANGELOG.md
|
|
@ -1,6 +1,152 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased] - Branch: feature/security
|
## [2.0.1] - 2025-12-01
|
||||||
|
|
||||||
|
|
||||||
|
## [2.0.0] - 2025-11-30
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- ENV-Struktur massiv vereinfacht (Phase 6)
|
||||||
|
- Add consent change and deletion notifications (Phase 4)
|
||||||
|
- Add upload notifications to Telegram Bot (Phase 3)
|
||||||
|
- Add TelegramNotificationService (Phase 2)
|
||||||
|
- Add Telegram Bot standalone test (Phase 1)
|
||||||
|
- Add Telegram notification feature request and improve prod.sh Docker registry push
|
||||||
|
|
||||||
|
### 🔧 Chores
|
||||||
|
- Add package.json for Telegram test scripts
|
||||||
|
|
||||||
|
|
||||||
|
## [1.10.2] - 2025-11-29
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- Auto-push releases with --follow-tags
|
||||||
|
|
||||||
|
|
||||||
|
## [1.10.1] - 2025-11-29
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- Update Footer.js version to 1.10.0 and fix sync-version.sh regex
|
||||||
|
|
||||||
|
### ♻️ Refactoring
|
||||||
|
- Use package.json version directly in Footer instead of env variables
|
||||||
|
|
||||||
|
|
||||||
|
## [1.10.0] - 2025-11-29
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- Enable drag-and-drop reordering in ModerationGroupImagesPage
|
||||||
|
- Error handling system and animated error pages
|
||||||
|
|
||||||
|
### ♻️ Refactoring
|
||||||
|
- Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
|
||||||
|
- Consolidate error pages into single ErrorPage component
|
||||||
|
- Centralized styling with CSS and global MUI overrides
|
||||||
|
|
||||||
|
### 🔧 Chores
|
||||||
|
- Improve release script with tag-based commit detection
|
||||||
|
|
||||||
|
|
||||||
|
## Public/Internal Host Separation (November 25, 2025)
|
||||||
|
|
||||||
|
### 🌐 Public/Internal Host Separation (November 25, 2025)
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
- ✅ **Host-Based Access Control**: Implemented `hostGate` middleware for subdomain-based feature separation
|
||||||
|
- Public host blocks internal routes: `/api/admin/*`, `/api/groups`, `/api/slideshow`, `/api/social-media/*`, `/api/auth/*`
|
||||||
|
- Public host allows: `/api/upload`, `/api/manage/:token`, `/api/previews`, `/api/consent`, `/api/social-media/platforms`
|
||||||
|
- Host detection via `X-Forwarded-Host` (nginx-proxy-manager) or `Host` header
|
||||||
|
- Environment variables: `PUBLIC_HOST`, `INTERNAL_HOST`, `ENABLE_HOST_RESTRICTION`, `TRUST_PROXY_HOPS`
|
||||||
|
|
||||||
|
- ✅ **Rate Limiting for Public Host**: IP-based upload rate limiting
|
||||||
|
- `publicUploadLimiter`: 20 uploads per hour for public host
|
||||||
|
- Internal host: No rate limits
|
||||||
|
- In-memory tracking with automatic cleanup
|
||||||
|
|
||||||
|
- ✅ **Audit Log Enhancement**: Extended audit logging with source tracking
|
||||||
|
- New columns: `source_host`, `source_type` in `management_audit_log`
|
||||||
|
- Tracks: `req.requestSource` (public/internal) for all management actions
|
||||||
|
- Database migration 009: Added source tracking columns
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
- ✅ **Host Detection Utility**: Runtime host detection for feature flags
|
||||||
|
- `hostDetection.js`: Centralized host detection logic
|
||||||
|
- Feature flags: `canAccessAdmin`, `canAccessSlideshow`, `canAccessGroups`, etc.
|
||||||
|
- Runtime config from `window._env_.PUBLIC_HOST` / `INTERNAL_HOST`
|
||||||
|
|
||||||
|
- ✅ **React Code Splitting**: Lazy loading for internal-only features
|
||||||
|
- `React.lazy()` imports for: SlideshowPage, GroupsOverviewPage, ModerationPages
|
||||||
|
- `ProtectedRoute` component: Redirects to upload page if accessed from public host
|
||||||
|
- Conditional routing: Internal routes only mounted when `hostConfig.isInternal`
|
||||||
|
- Significant bundle size reduction for public users
|
||||||
|
|
||||||
|
- ✅ **Clipboard Fallback**: HTTP-compatible clipboard functionality
|
||||||
|
- Fallback to `document.execCommand('copy')` when `navigator.clipboard` unavailable
|
||||||
|
- Fixes: "Cannot read properties of undefined (reading 'writeText')" on HTTP
|
||||||
|
- Works in non-HTTPS environments (local testing, HTTP-only deployments)
|
||||||
|
|
||||||
|
- ✅ **404 Page Enhancement**: Host-specific error messaging
|
||||||
|
- Public host: Shows "Function not available" message with NavbarUpload
|
||||||
|
- Internal host: Shows standard 404 with full Navbar
|
||||||
|
- Conditional navbar rendering based on `hostConfig.isPublic`
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
- ✅ **Environment Setup**: Complete configuration for dev/prod environments
|
||||||
|
- `docker/dev/docker-compose.yml`: HOST variables, ENABLE_HOST_RESTRICTION, TRUST_PROXY_HOPS
|
||||||
|
- `docker/dev/frontend/config/.env`: PUBLIC_HOST, INTERNAL_HOST added
|
||||||
|
- Frontend `.env.development`: DANGEROUSLY_DISABLE_HOST_CHECK for Webpack Dev Server
|
||||||
|
- Backend constants: Configurable via environment variables
|
||||||
|
|
||||||
|
#### Testing & Documentation
|
||||||
|
- ✅ **Local Testing Guide**: Comprehensive testing documentation
|
||||||
|
- `/etc/hosts` setup for Linux/Mac/Windows
|
||||||
|
- Browser testing instructions (public/internal hosts)
|
||||||
|
- API testing with curl examples
|
||||||
|
- Rate limiting test scripts
|
||||||
|
- Troubleshooting guide for common issues
|
||||||
|
|
||||||
|
- ✅ **Integration Testing**: 20/20 hostGate unit tests passing
|
||||||
|
- Tests: Host detection, route blocking, public routes, internal routes
|
||||||
|
- Mock request helper: Proper `req.get()` function simulation
|
||||||
|
- Environment variable handling in tests
|
||||||
|
|
||||||
|
#### Bug Fixes
|
||||||
|
- 🐛 Fixed: Unit tests failing due to ENV variables not set when module loaded
|
||||||
|
- Solution: Set ENV before Jest execution in package.json test script
|
||||||
|
- 🐛 Fixed: `req.get()` mock not returning header values in tests
|
||||||
|
- Solution: Created `createMockRequest()` helper with proper function implementation
|
||||||
|
- 🐛 Fixed: Webpack "Invalid Host header" error with custom hostnames
|
||||||
|
- Solution: Added `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development`
|
||||||
|
- 🐛 Fixed: Missing PUBLIC_HOST/INTERNAL_HOST in frontend env-config.js
|
||||||
|
- Solution: Added variables to `docker/dev/frontend/config/.env`
|
||||||
|
- 🐛 Fixed: Wrong navbar (Navbar instead of NavbarUpload) on 404 page for public host
|
||||||
|
- Solution: Conditional rendering `{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}`
|
||||||
|
- 🐛 Fixed: "Plattformen konnten nicht geladen werden" in UUID Management mode
|
||||||
|
- Solution: Added `/api/social-media/platforms` to PUBLIC_ALLOWED_ROUTES
|
||||||
|
|
||||||
|
#### Technical Details
|
||||||
|
- **Backend Changes**:
|
||||||
|
- New files: `middlewares/hostGate.js`, `middlewares/rateLimiter.js` (publicUploadLimiter)
|
||||||
|
- Modified files: `server.js` (hostGate registration), `auditLog.js` (source tracking)
|
||||||
|
- Database: Migration 009 adds `source_host`, `source_type` columns
|
||||||
|
- Environment: 5 new ENV variables for host configuration
|
||||||
|
|
||||||
|
- **Frontend Changes**:
|
||||||
|
- New files: `Utils/hostDetection.js` (214 lines)
|
||||||
|
- Modified files: `App.js` (lazy loading + ProtectedRoute), `404Page.js` (conditional navbar)
|
||||||
|
- Modified files: `MultiUploadPage.js`, `UploadSuccessDialog.js` (clipboard fallback)
|
||||||
|
- Modified files: `env-config.js`, `public/env-config.js` (HOST variables)
|
||||||
|
- New file: `.env.development` (Webpack host check bypass)
|
||||||
|
|
||||||
|
- **Production Impact**:
|
||||||
|
- nginx-proxy-manager setup required for subdomain routing
|
||||||
|
- Must forward `X-Forwarded-Host` header to backend
|
||||||
|
- Set `TRUST_PROXY_HOPS=1` when behind nginx-proxy-manager
|
||||||
|
- Public host users get 96% smaller JavaScript bundle (code splitting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## feature/security
|
||||||
|
|
||||||
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
|
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
|
||||||
|
|
||||||
|
|
@ -22,7 +168,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased] - Branch: feature/SocialMedia
|
## feature/SocialMedia
|
||||||
|
|
||||||
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
|
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
|
||||||
|
|
||||||
|
|
@ -271,7 +417,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased] - Branch: feature/PreloadImage
|
## Preload Image
|
||||||
|
|
||||||
### 🚀 Slideshow Optimization (November 2025)
|
### 🚀 Slideshow Optimization (November 2025)
|
||||||
|
|
||||||
|
|
@ -308,7 +454,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased] - Branch: feature/DeleteUnprovedGroups
|
## Delete Unproved Groups
|
||||||
|
|
||||||
### ✨ Automatic Cleanup Feature (November 2025)
|
### ✨ Automatic Cleanup Feature (November 2025)
|
||||||
|
|
||||||
|
|
@ -375,7 +521,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased] - Branch: feature/ImageDescription
|
## Image Description
|
||||||
|
|
||||||
### ✨ Image Descriptions Feature (November 2025)
|
### ✨ Image Descriptions Feature (November 2025)
|
||||||
|
|
||||||
|
|
@ -449,7 +595,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased] - Branch: upgrade/deps-react-node-20251028
|
## Upgrade Deps: React & Node (October 2025)
|
||||||
|
|
||||||
### 🎯 Major Framework Upgrades (October 2025)
|
### 🎯 Major Framework Upgrades (October 2025)
|
||||||
|
|
||||||
|
|
|
||||||
1170
FeatureRequests/done/FEATURE_PLAN-FrontendPublic.md
Normal file
1170
FeatureRequests/done/FEATURE_PLAN-FrontendPublic.md
Normal file
File diff suppressed because it is too large
Load Diff
385
FeatureRequests/done/FEATURE_PLAN-telegram.md
Normal file
385
FeatureRequests/done/FEATURE_PLAN-telegram.md
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
# Feature Plan: Telegram Bot Integration
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Implementierung eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
|
||||||
|
|
||||||
|
**Basis:** [FEATURE_REQUEST-telegram.md](./FEATURE_REQUEST-telegram.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasen-Aufteilung
|
||||||
|
|
||||||
|
### Phase 1: Bot Setup & Standalone-Test
|
||||||
|
**Ziel:** Telegram Bot erstellen und isoliert testen (ohne App-Integration)
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] Telegram Bot via BotFather erstellt
|
||||||
|
- [x] Bot zu Test-Telegram-Gruppe hinzugefügt
|
||||||
|
- [x] Chat-ID ermittelt
|
||||||
|
- [x] `scripts/telegram-test.js` - Standalone Test-Script
|
||||||
|
- [x] `scripts/README.telegram.md` - Setup-Anleitung
|
||||||
|
- [x] `.env.telegram` - Template für Bot-Credentials
|
||||||
|
- [x] Erfolgreiche Test-Nachricht versendet
|
||||||
|
|
||||||
|
**Akzeptanzkriterium:**
|
||||||
|
✅ Bot sendet erfolgreich Nachricht an Testgruppe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Backend-Service Integration
|
||||||
|
**Ziel:** TelegramNotificationService in Backend integrieren
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Dependencies:** Phase 1 abgeschlossen
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] `backend/src/services/TelegramNotificationService.js`
|
||||||
|
- [x] ENV-Variablen in `docker/dev/backend/config/.env`
|
||||||
|
- [x] Unit-Tests für Service
|
||||||
|
- [x] Docker Dev Environment funktioniert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Upload-Benachrichtigungen
|
||||||
|
**Ziel:** Automatische Benachrichtigungen bei neuem Upload
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Dependencies:** Phase 2 abgeschlossen
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] Integration in `routes/batchUpload.js`
|
||||||
|
- [x] `sendUploadNotification()` Methode
|
||||||
|
- [x] Formatierung mit Icons/Emojis
|
||||||
|
- [x] Integration-Tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: User-Änderungs-Benachrichtigungen
|
||||||
|
**Ziel:** Benachrichtigungen bei Consent-Änderungen & Löschungen
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Dependencies:** Phase 3 abgeschlossen
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] Integration in `routes/management.js` (PUT/DELETE)
|
||||||
|
- [x] `sendConsentChangeNotification()` Methode
|
||||||
|
- [x] `sendGroupDeletedNotification()` Methode
|
||||||
|
- [x] Integration-Tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Tägliche Lösch-Warnungen
|
||||||
|
**Ziel:** Cron-Job für bevorstehende Löschungen
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Dependencies:** Phase 4 abgeschlossen
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] Cron-Job Setup (node-cron)
|
||||||
|
- [x] `sendDeletionWarning()` Methode
|
||||||
|
- [x] Admin-Route für manuellen Trigger (`POST /api/admin/telegram/warning`)
|
||||||
|
- [x] SchedulerService Integration (09:00 daily)
|
||||||
|
- [x] Docker ENV-Variablen konfiguriert
|
||||||
|
- [x] README.md Update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Production Deployment
|
||||||
|
**Ziel:** Rollout in Production-Umgebung + ENV-Vereinfachung
|
||||||
|
|
||||||
|
**Status:** 🟢 Abgeschlossen
|
||||||
|
|
||||||
|
**Dependencies:** Phase 1-5 abgeschlossen + getestet
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [x] ENV-Struktur vereinfachen (zu viele .env-Dateien!)
|
||||||
|
- [x] Production ENV-Variablen in docker/prod/.env konfigurieren
|
||||||
|
- [x] docker/prod/docker-compose.yml mit Telegram-ENV erweitern
|
||||||
|
- [x] Consent-Änderung Bug Fix (platform_name statt name)
|
||||||
|
- [x] README.md Update mit ENV-Struktur Dokumentation
|
||||||
|
- ⏭️ Bot in echte Werkstatt-Gruppe einfügen (optional, bei Bedarf)
|
||||||
|
- ⏭️ Production Testing (optional, bei Bedarf)
|
||||||
|
|
||||||
|
**ENV-Vereinfachung (Abgeschlossen):**
|
||||||
|
```
|
||||||
|
Vorher: 16 .env-Dateien mit redundanter Konfiguration
|
||||||
|
Nachher: 2 zentrale .env-Dateien
|
||||||
|
✅ docker/dev/.env (alle dev secrets)
|
||||||
|
✅ docker/prod/.env (alle prod secrets)
|
||||||
|
✅ docker-compose.yml nutzt ${VAR} Platzhalter
|
||||||
|
✅ Gemountete .env-Dateien entfernt (wurden überschrieben)
|
||||||
|
✅ Alle ENV-Variablen in docker-compose environment
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 - Detaillierter Plan
|
||||||
|
|
||||||
|
### 1. Vorbereitung (5 min)
|
||||||
|
|
||||||
|
**Auf Windows 11 Host-System:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js Version prüfen
|
||||||
|
node --version # Sollte >= 18.x sein
|
||||||
|
|
||||||
|
# Projektverzeichnis öffnen
|
||||||
|
cd /home/lotzm/gitea.hobbyhimmel/Project-Image-Uploader/scripts
|
||||||
|
|
||||||
|
# Dependencies installieren (lokal)
|
||||||
|
npm init -y # Falls noch keine package.json
|
||||||
|
npm install node-telegram-bot-api dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Telegram Bot erstellen (10 min)
|
||||||
|
|
||||||
|
**Anleitung:** Siehe `scripts/README.telegram.md`
|
||||||
|
|
||||||
|
**Schritte:**
|
||||||
|
1. Telegram öffnen (Windows 11 App)
|
||||||
|
2. [@BotFather](https://t.me/botfather) suchen
|
||||||
|
3. `/newbot` Command
|
||||||
|
4. Bot-Name: "Werkstatt Image Uploader Bot"
|
||||||
|
5. Username: `werkstatt_uploader_bot` (oder verfügbar)
|
||||||
|
6. **Token kopieren** → `.env.telegram`
|
||||||
|
|
||||||
|
### 3. Test-Gruppe erstellen & Bot hinzufügen (5 min)
|
||||||
|
|
||||||
|
**Schritte:**
|
||||||
|
1. Neue Telegram-Gruppe erstellen: "Werkstatt Upload Bot Test"
|
||||||
|
2. Bot zur Gruppe hinzufügen: @werkstatt_uploader_bot
|
||||||
|
3. **Chat-ID ermitteln** (siehe README.telegram.md)
|
||||||
|
4. Chat-ID speichern → `.env.telegram`
|
||||||
|
|
||||||
|
### 4. Test-Script erstellen (10 min)
|
||||||
|
|
||||||
|
**Datei:** `scripts/telegram-test.js`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Lädt `.env.telegram`
|
||||||
|
- Validiert Bot-Token
|
||||||
|
- Sendet Test-Nachricht
|
||||||
|
- Error-Handling
|
||||||
|
|
||||||
|
### 5. Erste Nachricht senden (2 min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
node telegram-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartete Ausgabe:**
|
||||||
|
```
|
||||||
|
✅ Telegram Bot erfolgreich verbunden!
|
||||||
|
Bot-Name: Werkstatt Image Uploader Bot
|
||||||
|
Bot-Username: @werkstatt_uploader_bot
|
||||||
|
|
||||||
|
📤 Sende Test-Nachricht an Chat -1001234567890...
|
||||||
|
✅ Nachricht erfolgreich gesendet!
|
||||||
|
```
|
||||||
|
|
||||||
|
**In Telegram-Gruppe:**
|
||||||
|
```
|
||||||
|
🤖 Telegram Bot Test
|
||||||
|
|
||||||
|
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
|
||||||
|
|
||||||
|
Status: ✅ Erfolgreich verbunden!
|
||||||
|
Zeitstempel: 2025-11-29 14:23:45
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateistruktur (Phase 1)
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── README.telegram.md # Setup-Anleitung (NEU)
|
||||||
|
├── telegram-test.js # Test-Script (NEU)
|
||||||
|
├── .env.telegram.example # ENV-Template (NEU)
|
||||||
|
├── .env.telegram # Echte Credentials (gitignored, NEU)
|
||||||
|
├── package.json # Lokale Dependencies (NEU)
|
||||||
|
└── node_modules/ # npm packages (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables (Phase 1)
|
||||||
|
|
||||||
|
**Datei:** `scripts/.env.telegram`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=-1001234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies (Phase 1)
|
||||||
|
|
||||||
|
**Package:** `scripts/package.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "telegram-test-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Standalone Telegram Bot Testing",
|
||||||
|
"main": "telegram-test.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node telegram-test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit (Phase 1)
|
||||||
|
|
||||||
|
**`.gitignore` ergänzen:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Telegram Credentials
|
||||||
|
scripts/.env.telegram
|
||||||
|
scripts/node_modules/
|
||||||
|
scripts/package-lock.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:**
|
||||||
|
- ❌ Niemals `.env.telegram` committen!
|
||||||
|
- ✅ Nur `.env.telegram.example` (ohne echte Tokens) committen
|
||||||
|
- ✅ Bot-Token regenerieren, falls versehentlich exposed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist (Phase 1)
|
||||||
|
|
||||||
|
- [x] Node.js Version >= 18.x
|
||||||
|
- [x] Telegram App installiert (Windows 11)
|
||||||
|
- [x] Bot via BotFather erstellt
|
||||||
|
- [x] Bot-Token gespeichert in `.env.telegram`
|
||||||
|
- [x] Test-Gruppe erstellt
|
||||||
|
- [x] Bot zur Gruppe hinzugefügt
|
||||||
|
- [x] Chat-ID ermittelt
|
||||||
|
- [x] Chat-ID gespeichert in `.env.telegram`
|
||||||
|
- [x] Privacy Mode deaktiviert
|
||||||
|
- [x] Test-Nachricht erfolgreich gesendet
|
||||||
|
- [ ] `npm install` erfolgreich
|
||||||
|
- [ ] `node telegram-test.js` läuft ohne Fehler
|
||||||
|
- [ ] Test-Nachricht in Telegram-Gruppe empfangen
|
||||||
|
- [ ] Formatierung (Emojis, Zeilenumbrüche) korrekt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting (Phase 1)
|
||||||
|
|
||||||
|
### Problem: "Unauthorized (401)"
|
||||||
|
**Lösung:** Bot-Token falsch → BotFather prüfen, `.env.telegram` korrigieren
|
||||||
|
|
||||||
|
### Problem: "Bad Request: chat not found"
|
||||||
|
**Lösung:** Chat-ID falsch → Neue Nachricht in Gruppe senden, Chat-ID neu ermitteln
|
||||||
|
|
||||||
|
### Problem: "ETELEGRAM: 403 Forbidden"
|
||||||
|
**Lösung:** Bot wurde aus Gruppe entfernt → Bot erneut zur Gruppe hinzufügen
|
||||||
|
|
||||||
|
### Problem: "Module not found: node-telegram-bot-api"
|
||||||
|
**Lösung:**
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte (nach Phase 1)
|
||||||
|
|
||||||
|
1. **Code-Review:** `scripts/telegram-test.js`
|
||||||
|
2. **Dokumentation Review:** `scripts/README.telegram.md`
|
||||||
|
3. **Commit:**
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat: Add Telegram Bot standalone test (Phase 1)"
|
||||||
|
```
|
||||||
|
4. **Phase 2 starten:** Backend-Integration planen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zeitschätzung
|
||||||
|
|
||||||
|
| Phase | Aufwand | Beschreibung |
|
||||||
|
|-------|---------|--------------|
|
||||||
|
| **Phase 1** | **~45 min** | Bot Setup + Standalone-Test |
|
||||||
|
| Phase 2 | ~2h | Backend-Service |
|
||||||
|
| Phase 3 | ~2h | Upload-Benachrichtigungen |
|
||||||
|
| Phase 4 | ~2h | Änderungs-Benachrichtigungen |
|
||||||
|
| Phase 5 | ~2h | Cron-Job |
|
||||||
|
| Phase 6 | ~1h | Production Deployment |
|
||||||
|
| **Gesamt** | **~9-10h** | Vollständige Integration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventional Commits (ab Phase 1)
|
||||||
|
|
||||||
|
**Phase 1:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: Add Telegram Bot test script"
|
||||||
|
git commit -m "docs: Add Telegram Bot setup guide"
|
||||||
|
git commit -m "chore: Add node-telegram-bot-api dependency to scripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 2:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: Add TelegramNotificationService"
|
||||||
|
git commit -m "test: Add TelegramNotificationService unit tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Phase 3-6:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: Add upload notification to Telegram"
|
||||||
|
git commit -m "feat: Add consent change notifications"
|
||||||
|
git commit -m "feat: Add daily deletion warnings cron job"
|
||||||
|
git commit -m "docs: Update README with Telegram features"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release-Planung
|
||||||
|
|
||||||
|
**Phase 1:** Kein Release (interne Tests)
|
||||||
|
|
||||||
|
**Phase 6 (Final):**
|
||||||
|
- **Version:** `2.0.0` (Major Release)
|
||||||
|
- **Branch:** `feature/telegram-notifications`
|
||||||
|
- **Release-Command:** `npm run release:major`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-Tracking
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** 2025-11-30
|
||||||
|
|
||||||
|
| Phase | Status | Datum |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| Phase 1 | 🟢 Abgeschlossen | 2025-11-29 |
|
||||||
|
| Phase 2 | 🟢 Abgeschlossen | 2025-11-29 |
|
||||||
|
| Phase 3 | 🟢 Abgeschlossen | 2025-11-29 |
|
||||||
|
| Phase 4 | 🟢 Abgeschlossen | 2025-11-30 |
|
||||||
|
| Phase 5 | 🟢 Abgeschlossen | 2025-11-30 |
|
||||||
|
| Phase 6 | 🟡 ENV vereinfacht | 2025-11-30 |
|
||||||
|
|
||||||
|
**Legende:**
|
||||||
|
- 🟢 Abgeschlossen
|
||||||
|
- 🟡 In Arbeit
|
||||||
|
- 🔴 Blockiert
|
||||||
|
- ⚪ Ausstehend
|
||||||
|
|
@ -11,15 +11,14 @@ Es soll unterschieden werden, welche Funktionen der App abhängig von der aufger
|
||||||
- `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar.
|
- `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar.
|
||||||
- `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend.
|
- `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend.
|
||||||
|
|
||||||
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet.
|
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet (dns challenge letsencrypt).
|
||||||
|
|
||||||
Es wäre optional möglich, das public-Frontend extern zu hosten und nur die entsprechenden API-Endpunkte öffentlich verfügbar zu machen.
|
|
||||||
|
|
||||||
## Ziele
|
## Ziele
|
||||||
|
|
||||||
- Sicherheit: Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen.
|
- Sicherheit: Slideshow, Groupview und Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen.
|
||||||
- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar.
|
- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar. (die Upload Seite ist bereits so gestalltet, dass keine Menüpunkte sichtbar sind)
|
||||||
- Flexibilität: Support sowohl für ein und denselben Host (Host-Header-Check) als auch für separat gehostetes public-Frontend.
|
|
||||||
|
|
||||||
## Vorschlag — Technische Umsetzung (hoher Level)
|
## Vorschlag — Technische Umsetzung (hoher Level)
|
||||||
|
|
||||||
|
|
@ -80,23 +79,21 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA
|
||||||
|
|
||||||
1. Domains — exakte Hosts
|
1. Domains — exakte Hosts
|
||||||
- Dokumentation: Platzhalter-Hosts wurden als Beispiele verwendet (z. B. `deinprojekt.meindomain.de` und `deinprojekt.lan.meindomain.de`).
|
- Dokumentation: Platzhalter-Hosts wurden als Beispiele verwendet (z. B. `deinprojekt.meindomain.de` und `deinprojekt.lan.meindomain.de`).
|
||||||
- Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. Beispiel‑Antwort reicht: `public.example.com` und `public.lan.example.com`.
|
- Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. Beispiel‑Antwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`.
|
||||||
|
|
||||||
2. Host-Check vs. zusätzliche Checks
|
2. Host-Check vs. zusätzliche Checks
|
||||||
- Doku: Admin‑API ist bereits serverseitig per Bearer‑Token (`ADMIN_API_KEY`) geschützt. Management‑API nutzt UUID‑Token mit Rate‑Limits (10 req/h) und Brute‑Force‑Schutz.
|
- Doku: Admin‑API ist bereits serverseitig per Admin Login geschützt. Management‑API nutzt UUID‑Token mit Rate‑Limits (10 req/h) und Brute‑Force‑Schutz.
|
||||||
- Empfehlung: Primär Host‑Header (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für Admin‑APIs die Kombination aus Bearer‑Token + Host‑Check (defense in depth). Bitte bestätigen, ob IP‑Whitelist gewünscht ist.
|
- Empfehlung: Primär Host‑Header (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für Admin‑APIs die Kombination aus Bearer‑Token + Host‑Check (defense in depth). Bitte bestätigen, ob IP‑Whitelist gewünscht ist.
|
||||||
|
|
||||||
3. Externes Hosting des public‑Frontends
|
3. Externes Hosting des public‑Frontends -> nicht mehr nötig
|
||||||
- Doku: Assets und Server liegen standardmäßig lokal (backend `src/data/images` / `src/data/previews`). Externes Hosting ist nicht Teil der Standardkonfiguration.
|
|
||||||
- Empfehlung: Behalte Assets intern (Standard). Wenn Du extern hosten willst, müssen CORS, Allowlist und ggf. signierte URLs implementiert werden. Bestätige, ob externes Hosting geplant ist.
|
|
||||||
|
|
||||||
4. Management‑UUID (Editieren von extern)
|
4. Management‑UUID (Editieren von extern)
|
||||||
- Doku: Management‑Tokens sind permanent gültig bis Gruppe gelöscht; Token sind URL‑basiert und Rate‑limited (10 req/h). README zeigt, dass Management‑Portal für Self‑Service gedacht ist und kein zusätzliches network restriction vorgesehen ist.
|
- Doku: Management‑Tokens sind permanent gültig bis Gruppe gelöscht; Token sind URL‑basiert und Rate‑limited (10 req/h). README zeigt, dass Management‑Portal für Self‑Service gedacht ist und kein zusätzliches network restriction vorgesehen ist.
|
||||||
- Schlussfolgerung: Editieren per UUID ist technisch erlaubt und im Projekt vorgesehen. Wenn Du das beibehalten willst, ist keine weitere technische Änderung nötig. Falls Du TTL für Tokens möchtest, bitte angeben.
|
- Schlussfolgerung: Editieren per UUID ist technisch erlaubt und im Projekt vorgesehen. Wenn Du das beibehalten willst, ist keine weitere technische Änderung nötig. Falls Du TTL für Tokens möchtest, bitte angeben.
|
||||||
|
|
||||||
5. Admin‑APIs: Host‑only oder zusätzlich Bearer‑Token?
|
5. Admin‑APIs: Host‑only oder zusätzlich Bearer‑Token?
|
||||||
- Doku: Admin APIs sind bereits durch Bearer‑Token geschützt (`ADMIN_API_KEY`).
|
- ~~Doku: Admin APIs sind bereits durch Bearer‑Token geschützt (`ADMIN_API_KEY`).~~
|
||||||
- Empfehlung: Behalte Bearer‑Token als Hauptschutz und ergänze Host‑Restriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen.
|
- ~~Empfehlung: Behalte Bearer‑Token als Hauptschutz und ergänze Host‑Restriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen.~~
|
||||||
|
|
||||||
6. Rate‑Limits / Quotas für public Uploads
|
6. Rate‑Limits / Quotas für public Uploads
|
||||||
- Doku: Management hat 10 req/h per IP; Upload‑Rate‑Limits für public uploads sind nicht konkret spezifiziert.
|
- Doku: Management hat 10 req/h per IP; Upload‑Rate‑Limits für public uploads sind nicht konkret spezifiziert.
|
||||||
|
|
@ -104,7 +101,7 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA
|
||||||
|
|
||||||
7. Logging / Monitoring
|
7. Logging / Monitoring
|
||||||
- Doku: Es gibt umfassende Audit-Logs (`management_audit_log`, `deletion_log`).
|
- Doku: Es gibt umfassende Audit-Logs (`management_audit_log`, `deletion_log`).
|
||||||
- Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen?
|
- Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen? Passt!
|
||||||
|
|
||||||
8. Assets / CDN
|
8. Assets / CDN
|
||||||
- Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUID‑Links zugänglich.
|
- Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUID‑Links zugänglich.
|
||||||
450
FeatureRequests/done/FEATURE_REQUEST-telegram.md
Normal file
450
FeatureRequests/done/FEATURE_REQUEST-telegram.md
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
# Feature Request: Telegram Bot für Benachrichtigungen
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Integration eines Telegram Bots zur automatischen Benachrichtigung der Werkstatt-Gruppe über wichtige Events im Image Uploader System.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Werkstatt-Mitarbeiter sollen zeitnah über neue Uploads, Änderungen und bevorstehende Löschungen informiert werden, ohne ständig das Admin-Panel prüfen zu müssen.
|
||||||
|
|
||||||
|
## Use Case
|
||||||
|
|
||||||
|
Die Offene Werkstatt hat eine Telegram Gruppe, in der das Team kommuniziert. Der Bot wird zu dieser Gruppe hinzugefügt und sendet automatisierte Benachrichtigungen bei relevanten Events.
|
||||||
|
|
||||||
|
## Funktionale Anforderungen
|
||||||
|
|
||||||
|
### 1. Benachrichtigung: Neuer Upload
|
||||||
|
|
||||||
|
**Trigger:** Erfolgreicher Batch-Upload über `/api/upload-batch`
|
||||||
|
|
||||||
|
**Nachricht enthält:**
|
||||||
|
- 📸 Upload-Icon
|
||||||
|
- Name des Uploaders
|
||||||
|
- Anzahl der hochgeladenen Bilder
|
||||||
|
- Jahr der Gruppe
|
||||||
|
- Titel der Gruppe
|
||||||
|
- Workshop-Consent Status (✅ Ja / ❌ Nein)
|
||||||
|
- Social Media Consents (Facebook, Instagram, TikTok Icons)
|
||||||
|
- Link zum Admin-Panel (Moderation)
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
📸 Neuer Upload!
|
||||||
|
|
||||||
|
Uploader: Max Mustermann
|
||||||
|
Bilder: 12
|
||||||
|
Gruppe: 2024 - Schweißkurs November
|
||||||
|
Workshop: ✅ Ja
|
||||||
|
Social Media: 📘 Instagram, 🎵 TikTok
|
||||||
|
|
||||||
|
🔗 Zur Freigabe: https://internal.hobbyhimmel.de/moderation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Benachrichtigung: User-Änderungen
|
||||||
|
|
||||||
|
**Trigger:**
|
||||||
|
- `PUT /api/manage/:token` (Consent-Änderung)
|
||||||
|
- `DELETE /api/manage/:token/groups/:groupId` (Gruppenl löschung durch User)
|
||||||
|
|
||||||
|
**Nachricht enthält:**
|
||||||
|
- ⚙️ Änderungs-Icon
|
||||||
|
- Art der Änderung (Consent Update / Gruppe gelöscht)
|
||||||
|
- Betroffene Gruppe (Jahr + Titel)
|
||||||
|
- Uploader-Name
|
||||||
|
- Neue Consent-Werte (bei Update)
|
||||||
|
|
||||||
|
**Beispiel (Consent-Änderung):**
|
||||||
|
```
|
||||||
|
⚙️ User-Änderung
|
||||||
|
|
||||||
|
Aktion: Consent aktualisiert
|
||||||
|
Gruppe: 2024 - Schweißkurs November
|
||||||
|
Uploader: Max Mustermann
|
||||||
|
|
||||||
|
Neu:
|
||||||
|
Workshop: ❌ Nein (vorher: ✅)
|
||||||
|
Social Media: 📘 Instagram (TikTok entfernt)
|
||||||
|
|
||||||
|
🔗 Details: https://internal.hobbyhimmel.de/moderation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel (Gruppe gelöscht):**
|
||||||
|
```
|
||||||
|
⚙️ User-Änderung
|
||||||
|
|
||||||
|
Aktion: Gruppe gelöscht
|
||||||
|
Gruppe: 2024 - Schweißkurs November
|
||||||
|
Uploader: Max Mustermann
|
||||||
|
Bilder: 12
|
||||||
|
|
||||||
|
ℹ️ User hat Gruppe selbst über Management-Link gelöscht
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Benachrichtigung: Ablauf Freigabe / Löschung in 1 Tag
|
||||||
|
|
||||||
|
**Trigger:** Täglicher Cron-Job (z.B. 09:00 Uhr)
|
||||||
|
|
||||||
|
**Prüfung:**
|
||||||
|
- Alle nicht-freigegebenen Gruppen mit `created_at < NOW() - 6 days`
|
||||||
|
- Werden in 24 Stunden durch Cleanup-Service gelöscht
|
||||||
|
|
||||||
|
**Nachricht enthält:**
|
||||||
|
- ⏰ Warnung-Icon
|
||||||
|
- Liste aller betroffenen Gruppen
|
||||||
|
- Countdown bis Löschung
|
||||||
|
- Hinweis auf Freigabe-Möglichkeit
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
⏰ Löschung in 24 Stunden!
|
||||||
|
|
||||||
|
Folgende Gruppen werden morgen automatisch gelöscht:
|
||||||
|
|
||||||
|
1. 2024 - Schweißkurs November
|
||||||
|
Uploader: Max Mustermann
|
||||||
|
Bilder: 12
|
||||||
|
Hochgeladen: 20.11.2024
|
||||||
|
|
||||||
|
2. 2024 - Holzarbeiten Workshop
|
||||||
|
Uploader: Anna Schmidt
|
||||||
|
Bilder: 8
|
||||||
|
Hochgeladen: 21.11.2024
|
||||||
|
|
||||||
|
💡 Jetzt freigeben oder Freigabe bleibt aus!
|
||||||
|
🔗 Zur Moderation: https://internal.hobbyhimmel.de/moderation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technische Anforderungen
|
||||||
|
|
||||||
|
### Backend-Integration
|
||||||
|
|
||||||
|
**Neue Umgebungsvariablen:**
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=<bot-token>
|
||||||
|
TELEGRAM_CHAT_ID=<werkstatt-gruppen-id>
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Neue Service-Datei:** `backend/src/services/TelegramNotificationService.js`
|
||||||
|
|
||||||
|
**Methoden:**
|
||||||
|
- `sendUploadNotification(groupData)`
|
||||||
|
- `sendConsentChangeNotification(oldConsents, newConsents, groupData)`
|
||||||
|
- `sendGroupDeletedNotification(groupData)`
|
||||||
|
- `sendDeletionWarning(groupsList)`
|
||||||
|
|
||||||
|
**Integration Points:**
|
||||||
|
- `routes/batchUpload.js` → Nach erfolgreichem Upload
|
||||||
|
- `routes/management.js` → PUT/DELETE Endpoints
|
||||||
|
- `services/GroupCleanupService.js` → Neue Methode für tägliche Prüfung
|
||||||
|
|
||||||
|
### Telegram Bot Setup
|
||||||
|
|
||||||
|
**Bot erstellen:**
|
||||||
|
1. Mit [@BotFather](https://t.me/botfather) sprechen
|
||||||
|
2. `/newbot` → Bot-Name: "Werkstatt Image Uploader Bot"
|
||||||
|
3. Token speichern → `.env`
|
||||||
|
|
||||||
|
**Bot zur Gruppe hinzufügen:**
|
||||||
|
1. Bot zu Werkstatt-Gruppe einladen
|
||||||
|
2. Chat-ID ermitteln: `https://api.telegram.org/bot<TOKEN>/getUpdates`
|
||||||
|
3. Chat-ID speichern → `.env`
|
||||||
|
|
||||||
|
**Berechtigungen:**
|
||||||
|
- ✅ Can send messages
|
||||||
|
- ✅ Can send photos (optional, für Vorschau-Bilder)
|
||||||
|
- ❌ Keine Admin-Rechte nötig
|
||||||
|
|
||||||
|
### Cron-Job für tägliche Prüfung
|
||||||
|
|
||||||
|
**Optionen:**
|
||||||
|
|
||||||
|
**A) Node-Cron (empfohlen für Development):**
|
||||||
|
```javascript
|
||||||
|
// backend/src/services/TelegramScheduler.js
|
||||||
|
const cron = require('node-cron');
|
||||||
|
|
||||||
|
// Jeden Tag um 09:00 Uhr
|
||||||
|
cron.schedule('0 9 * * *', async () => {
|
||||||
|
await checkPendingDeletions();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**B) System Cron (Production):**
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
0 9 * * * curl -X POST http://localhost:5000/api/admin/telegram/check-deletions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Neue Route:** `POST /api/admin/telegram/check-deletions` (Admin-Auth)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Neue NPM Packages:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Development (.env)
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=-1001234567890
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
TELEGRAM_DAILY_CHECK_TIME=09:00
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
- Gleiche Variablen in `docker/prod/backend/config/.env`
|
||||||
|
- Cron-Job via Node-Cron oder System-Cron
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- ✅ Bot-Token niemals committen (`.env` nur)
|
||||||
|
- ✅ Chat-ID validieren (nur bekannte Gruppen)
|
||||||
|
- ✅ Keine sensiblen Daten in Nachrichten (keine Email, keine vollständigen Token)
|
||||||
|
- ✅ Rate-Limiting für Telegram API (max 30 msg/sec)
|
||||||
|
- ✅ Error-Handling: Wenn Telegram down → Upload funktioniert trotzdem
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Manuell:**
|
||||||
|
```bash
|
||||||
|
# Trigger Upload-Benachrichtigung
|
||||||
|
curl -X POST http://localhost:5001/api/upload-batch \
|
||||||
|
-F "images=@test.jpg" \
|
||||||
|
-F "year=2024" \
|
||||||
|
-F "title=Test Upload" \
|
||||||
|
-F "name=Test User" \
|
||||||
|
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
|
||||||
|
|
||||||
|
# Trigger Consent-Änderung
|
||||||
|
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
|
||||||
|
|
||||||
|
# Trigger tägliche Prüfung (Admin)
|
||||||
|
curl -X POST http://localhost:5001/api/admin/telegram/check-deletions \
|
||||||
|
-b cookies.txt -H "X-CSRF-Token: $CSRF"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatisiert:**
|
||||||
|
- Unit-Tests für `TelegramNotificationService.js`
|
||||||
|
- Mock Telegram API mit `nock`
|
||||||
|
- Prüfe Nachrichtenformat + Escaping
|
||||||
|
|
||||||
|
## Optional: Zukünftige Erweiterungen
|
||||||
|
|
||||||
|
- 📊 Wöchentlicher Statistik-Report (Uploads, Freigaben, Löschungen)
|
||||||
|
- 🖼️ Preview-Bild im Telegram (erstes Bild der Gruppe)
|
||||||
|
- 💬 Interaktive Buttons (z.B. "Freigeben", "Ablehnen") → Webhook
|
||||||
|
- 🔔 Admin-Befehle (`/stats`, `/pending`, `/cleanup`)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Bot sendet Nachricht bei neuem Upload
|
||||||
|
- [ ] Bot sendet Nachricht bei Consent-Änderung
|
||||||
|
- [ ] Bot sendet Nachricht bei User-Löschung
|
||||||
|
- [ ] Bot sendet tägliche Warnung für bevorstehende Löschungen (09:00 Uhr)
|
||||||
|
- [ ] Alle Nachrichten enthalten relevante Informationen + Link
|
||||||
|
- [ ] Telegram-Fehler brechen Upload/Änderungen nicht ab
|
||||||
|
- [ ] ENV-Variable `TELEGRAM_ENABLED=false` deaktiviert alle Benachrichtigungen
|
||||||
|
- [ ] README.dev.md enthält Setup-Anleitung
|
||||||
|
|
||||||
|
## Aufwandsschätzung
|
||||||
|
|
||||||
|
- Backend-Integration: ~4-6 Stunden
|
||||||
|
- Cron-Job Setup: ~2 Stunden
|
||||||
|
- Testing: ~2 Stunden
|
||||||
|
- Dokumentation: ~1 Stunde
|
||||||
|
|
||||||
|
**Gesamt: ~9-11 Stunden**
|
||||||
|
|
||||||
|
## Priorität
|
||||||
|
|
||||||
|
**Medium** - Verbessert Workflow, aber nicht kritisch für Kernfunktion
|
||||||
|
|
||||||
|
## Release-Planung
|
||||||
|
|
||||||
|
**Target Version:** `2.0.0` (Major Version)
|
||||||
|
|
||||||
|
**Begründung für Major Release:**
|
||||||
|
- Neue Infrastruktur-Abhängigkeit (Telegram Bot)
|
||||||
|
- Neue Umgebungsvariablen erforderlich
|
||||||
|
- Breaking Change: Optional, aber empfohlene Konfiguration
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Feature Branch erstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/telegram-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Conventional Commits verwenden
|
||||||
|
|
||||||
|
**Wichtig:** Alle Commits nach [Conventional Commits](https://www.conventionalcommits.org/) formatieren!
|
||||||
|
|
||||||
|
**Beispiele:**
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: Add TelegramNotificationService"
|
||||||
|
git commit -m "feat: Add upload notification endpoint"
|
||||||
|
git commit -m "feat: Add daily deletion warning cron job"
|
||||||
|
git commit -m "chore: Add node-telegram-bot-api dependency"
|
||||||
|
git commit -m "docs: Update README with Telegram setup"
|
||||||
|
git commit -m "test: Add TelegramNotificationService unit tests"
|
||||||
|
git commit -m "fix: Handle Telegram API rate limiting"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit-Typen:**
|
||||||
|
- `feat:` - Neue Features
|
||||||
|
- `fix:` - Bugfixes
|
||||||
|
- `docs:` - Dokumentation
|
||||||
|
- `test:` - Tests
|
||||||
|
- `chore:` - Dependencies, Config
|
||||||
|
- `refactor:` - Code-Umstrukturierung
|
||||||
|
|
||||||
|
→ **Wird automatisch im CHANGELOG.md gruppiert!**
|
||||||
|
|
||||||
|
### 3. Development Setup
|
||||||
|
|
||||||
|
**Docker Dev Environment nutzen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container starten
|
||||||
|
./dev.sh
|
||||||
|
|
||||||
|
# .env konfigurieren (Backend)
|
||||||
|
# docker/dev/backend/config/.env
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=-1001234567890
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
TELEGRAM_DAILY_CHECK_TIME=09:00
|
||||||
|
|
||||||
|
# Backend neu starten (lädt neue ENV-Variablen)
|
||||||
|
docker compose -f docker/dev/docker-compose.yml restart backend-dev
|
||||||
|
|
||||||
|
# Logs verfolgen
|
||||||
|
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests ausführen:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm test -- tests/unit/TelegramNotificationService.test.js
|
||||||
|
npm test -- tests/api/telegram.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dokumentation aktualisieren
|
||||||
|
|
||||||
|
**README.md** - User-Dokumentation ergänzen:
|
||||||
|
- [ ] Telegram-Bot Setup-Anleitung
|
||||||
|
- [ ] Benachrichtigungs-Features beschreiben
|
||||||
|
- [ ] ENV-Variablen dokumentieren
|
||||||
|
|
||||||
|
**README.dev.md** - Development-Doku ergänzen:
|
||||||
|
- [ ] Telegram-Bot Testing-Anleitung
|
||||||
|
- [ ] Cron-Job Debugging
|
||||||
|
- [ ] TelegramNotificationService API-Referenz
|
||||||
|
- [ ] Beispiel-Curl-Commands für manuelle Trigger
|
||||||
|
|
||||||
|
**Sektion in README.dev.md einfügen (z.B. nach "Cleanup-System testen"):**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Telegram-Benachrichtigungen testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bot-Token validieren:
|
||||||
|
curl https://api.telegram.org/bot<TOKEN>/getMe
|
||||||
|
|
||||||
|
# Chat-ID ermitteln:
|
||||||
|
curl https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
|
||||||
|
# Upload-Benachrichtigung testen:
|
||||||
|
# → Einfach Upload durchführen, Telegram-Gruppe prüfen
|
||||||
|
|
||||||
|
# Consent-Änderung testen:
|
||||||
|
curl -X PUT http://localhost:5001/api/manage/<TOKEN> \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"workshopConsent":false,"socialMediaConsents":[]}'
|
||||||
|
|
||||||
|
# Tägliche Löschwarnung manuell triggern:
|
||||||
|
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
|
||||||
|
-X POST http://localhost:5001/api/admin/telegram/check-deletions
|
||||||
|
```
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 5. Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Unit-Tests für `TelegramNotificationService.js` (min. 80% Coverage)
|
||||||
|
- [ ] Integration-Tests für alle 3 Benachrichtigungstypen
|
||||||
|
- [ ] Manueller Test: Upload → Telegram-Nachricht kommt an
|
||||||
|
- [ ] Manueller Test: Consent-Änderung → Telegram-Nachricht kommt an
|
||||||
|
- [ ] Manueller Test: User-Löschung → Telegram-Nachricht kommt an
|
||||||
|
- [ ] Manueller Test: Cron-Job (tägliche Warnung) funktioniert
|
||||||
|
- [ ] Error-Handling: Telegram down → Upload funktioniert trotzdem
|
||||||
|
- [ ] ENV `TELEGRAM_ENABLED=false` → Keine Nachrichten
|
||||||
|
|
||||||
|
### 6. Release erstellen
|
||||||
|
|
||||||
|
**Nach erfolgreicher Implementierung:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Änderungen committen (Conventional Commits!)
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Complete Telegram notification system"
|
||||||
|
|
||||||
|
# Feature Branch pushen
|
||||||
|
git push origin feature/telegram-notifications
|
||||||
|
|
||||||
|
# Merge in main (nach Review)
|
||||||
|
git checkout main
|
||||||
|
git merge feature/telegram-notifications
|
||||||
|
|
||||||
|
# Major Release erstellen (2.0.0)
|
||||||
|
npm run release:major
|
||||||
|
|
||||||
|
# CHANGELOG prüfen (wurde automatisch generiert!)
|
||||||
|
cat CHANGELOG.md
|
||||||
|
|
||||||
|
# Push mit Tags
|
||||||
|
git push --follow-tags
|
||||||
|
|
||||||
|
# Docker Images bauen und pushen
|
||||||
|
./prod.sh # Option 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Release Notes (automatisch in CHANGELOG.md):**
|
||||||
|
- ✨ Features: Telegram-Bot Integration (Upload, Änderungen, Lösch-Warnungen)
|
||||||
|
- 📚 Documentation: README.md + README.dev.md Updates
|
||||||
|
- 🧪 Tests: TelegramNotificationService Tests
|
||||||
|
|
||||||
|
### 7. Deployment
|
||||||
|
|
||||||
|
**Production .env updaten:**
|
||||||
|
```bash
|
||||||
|
# docker/prod/backend/config/.env
|
||||||
|
TELEGRAM_BOT_TOKEN=<production-token>
|
||||||
|
TELEGRAM_CHAT_ID=<production-chat-id>
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container neu starten:**
|
||||||
|
```bash
|
||||||
|
./prod.sh # Option 4: Container neu bauen und starten
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
⚠️ **Vor dem Release prüfen:**
|
||||||
|
- README.md enthält User-Setup-Anleitung
|
||||||
|
- README.dev.md enthält Developer-Anleitung
|
||||||
|
- Alle Tests bestehen (`npm test`)
|
||||||
|
- Docker Dev Setup funktioniert
|
||||||
|
- Conventional Commits verwendet
|
||||||
|
- CHANGELOG.md ist korrekt generiert
|
||||||
248
README.dev.md
248
README.dev.md
|
|
@ -302,6 +302,35 @@ describe('Example API', () => {
|
||||||
# 5. Log prüfen: GET http://localhost:5001/api/admin/deletion-log
|
# 5. Log prüfen: GET http://localhost:5001/api/admin/deletion-log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Telegram-Benachrichtigungen testen
|
||||||
|
|
||||||
|
**Voraussetzung:** Bot-Setup abgeschlossen (siehe `scripts/README.telegram.md`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. ENV-Variablen in docker/dev/backend/config/.env konfigurieren:
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
TELEGRAM_BOT_TOKEN=<dein-bot-token>
|
||||||
|
TELEGRAM_CHAT_ID=<deine-chat-id>
|
||||||
|
|
||||||
|
# 2. Backend neu starten (lädt neue ENV-Variablen):
|
||||||
|
docker compose -f docker/dev/docker-compose.yml restart backend-dev
|
||||||
|
|
||||||
|
# 3. Test-Nachricht wird automatisch beim Server-Start gesendet
|
||||||
|
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
|
||||||
|
|
||||||
|
# 4. Upload-Benachrichtigung testen (Phase 3+):
|
||||||
|
curl -X POST http://localhost:5001/api/upload-batch \
|
||||||
|
-F "images=@test.jpg" \
|
||||||
|
-F "year=2024" \
|
||||||
|
-F "title=Test Upload" \
|
||||||
|
-F "name=Test User" \
|
||||||
|
-F 'consents={"workshopConsent":true,"socialMediaConsents":[]}'
|
||||||
|
# → Prüfe Telegram-Gruppe auf Benachrichtigung
|
||||||
|
|
||||||
|
# 5. Service manuell deaktivieren:
|
||||||
|
TELEGRAM_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
### API-Tests
|
### API-Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -442,6 +471,225 @@ ln -s ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
||||||
Nach der Installation aktualisiert der Hook die Datei bei Bedarf und staged sie direkt.
|
Nach der Installation aktualisiert der Hook die Datei bei Bedarf und staged sie direkt.
|
||||||
Für lokale HTTP-Lab-Deployments nutze eine separate (gitignorierte) `docker-compose.override.yml`, um `ADMIN_SESSION_COOKIE_SECURE=false` nur zur Laufzeit zu setzen. Entfernen kannst du den Hook jederzeit über `rm .git/hooks/pre-commit`.
|
Für lokale HTTP-Lab-Deployments nutze eine separate (gitignorierte) `docker-compose.override.yml`, um `ADMIN_SESSION_COOKIE_SECURE=false` nur zur Laufzeit zu setzen. Entfernen kannst du den Hook jederzeit über `rm .git/hooks/pre-commit`.
|
||||||
|
|
||||||
|
## Host-Separation Testing (Public/Internal Hosts)
|
||||||
|
|
||||||
|
Die Applikation unterstützt eine Public/Internal Host-Separation für die Produktion. Lokal kann dies mit /etc/hosts-Einträgen getestet werden.
|
||||||
|
|
||||||
|
### Schnellstart: Lokales Testing mit /etc/hosts
|
||||||
|
|
||||||
|
**1. Hosts-Datei bearbeiten:**
|
||||||
|
|
||||||
|
**Linux / Mac:**
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (als Administrator):**
|
||||||
|
1. Notepad öffnen (als Administrator)
|
||||||
|
2. Datei öffnen: `C:\Windows\System32\drivers\etc\hosts`
|
||||||
|
3. Dateifilter auf "Alle Dateien" ändern
|
||||||
|
|
||||||
|
Füge hinzu:
|
||||||
|
```
|
||||||
|
127.0.0.1 public.test.local
|
||||||
|
127.0.0.1 internal.test.local
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Docker .env anpassen:**
|
||||||
|
|
||||||
|
Bearbeite `docker/dev/frontend/config/.env`:
|
||||||
|
```bash
|
||||||
|
API_URL=http://localhost:5001
|
||||||
|
CLIENT_URL=http://localhost:3000
|
||||||
|
APP_VERSION=1.1.0
|
||||||
|
PUBLIC_HOST=public.test.local
|
||||||
|
INTERNAL_HOST=internal.test.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Bearbeite `docker/dev/docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
backend-dev:
|
||||||
|
environment:
|
||||||
|
- PUBLIC_HOST=public.test.local
|
||||||
|
- INTERNAL_HOST=internal.test.local
|
||||||
|
- ENABLE_HOST_RESTRICTION=true
|
||||||
|
- TRUST_PROXY_HOPS=0
|
||||||
|
|
||||||
|
frontend-dev:
|
||||||
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- DANGEROUSLY_DISABLE_HOST_CHECK=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Container starten:**
|
||||||
|
```bash
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Im Browser testen:**
|
||||||
|
|
||||||
|
**Public Host** (`http://public.test.local:3000`):
|
||||||
|
- ✅ Upload-Seite funktioniert
|
||||||
|
- ✅ UUID Management funktioniert (`/manage/:token`)
|
||||||
|
- ✅ Social Media Badges angezeigt
|
||||||
|
- ❌ Kein Admin/Groups/Slideshow-Menü
|
||||||
|
- ❌ `/moderation` → 404
|
||||||
|
|
||||||
|
**Internal Host** (`http://internal.test.local:3000`):
|
||||||
|
- ✅ Alle Features verfügbar
|
||||||
|
- ✅ Admin-Bereich, Groups, Slideshow erreichbar
|
||||||
|
- ✅ Vollständiger API-Zugriff
|
||||||
|
|
||||||
|
### API-Tests mit curl
|
||||||
|
|
||||||
|
**Public Host - Blockierte Routen (403):**
|
||||||
|
```bash
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/admin/deletion-log
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/groups
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/auth/login
|
||||||
|
```
|
||||||
|
|
||||||
|
**Public Host - Erlaubte Routen:**
|
||||||
|
```bash
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/upload
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/manage/YOUR-UUID
|
||||||
|
curl -H "Host: public.test.local" http://localhost:5001/api/social-media/platforms
|
||||||
|
```
|
||||||
|
|
||||||
|
**Internal Host - Alle Routen:**
|
||||||
|
```bash
|
||||||
|
curl -H "Host: internal.test.local" http://localhost:5001/api/groups
|
||||||
|
curl -H "Host: internal.test.local" http://localhost:5001/api/admin/deletion-log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Code-Splitting testen
|
||||||
|
|
||||||
|
**Public Host:**
|
||||||
|
1. Browser DevTools → Network → JS Filter
|
||||||
|
2. Öffne `http://public.test.local:3000`
|
||||||
|
3. **Erwartung:** Slideshow/Admin/Groups-Bundles werden **nicht** geladen
|
||||||
|
4. Navigiere zu `/admin` → Redirect zu 404
|
||||||
|
|
||||||
|
**Internal Host:**
|
||||||
|
1. Öffne `http://internal.test.local:3000`
|
||||||
|
2. Navigiere zu `/slideshow`
|
||||||
|
3. **Erwartung:** Lazy-Bundle wird erst jetzt geladen (Code Splitting)
|
||||||
|
|
||||||
|
### Rate Limiting testen
|
||||||
|
|
||||||
|
Public Host: 20 Uploads/Stunde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for i in {1..25}; do
|
||||||
|
echo "Upload $i"
|
||||||
|
curl -X POST -H "Host: public.test.local" \
|
||||||
|
http://localhost:5001/api/upload \
|
||||||
|
-F "file=@test.jpg" -F "group=Test"
|
||||||
|
done
|
||||||
|
# Ab Upload 21: HTTP 429 (Too Many Requests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"Invalid Host header"**
|
||||||
|
- Lösung: `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development` (Frontend)
|
||||||
|
|
||||||
|
**"Alle Routen geben 403"**
|
||||||
|
- Prüfe `ENABLE_HOST_RESTRICTION=true`
|
||||||
|
- Prüfe `PUBLIC_HOST` / `INTERNAL_HOST` ENV-Variablen
|
||||||
|
- Container neu starten
|
||||||
|
|
||||||
|
**"public.test.local nicht erreichbar"**
|
||||||
|
- Prüfe `/etc/hosts`: `cat /etc/hosts | grep test.local`
|
||||||
|
- DNS-Cache leeren:
|
||||||
|
- **Linux:** `sudo systemd-resolve --flush-caches`
|
||||||
|
- **Mac:** `sudo dscacheutil -flushcache`
|
||||||
|
- **Windows:** `ipconfig /flushdns`
|
||||||
|
|
||||||
|
**Feature deaktivieren (Standard Dev):**
|
||||||
|
```yaml
|
||||||
|
backend-dev:
|
||||||
|
environment:
|
||||||
|
- ENABLE_HOST_RESTRICTION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
Für Production mit echten Subdomains siehe:
|
||||||
|
- `FeatureRequests/FEATURE_PLAN-FrontendPublic.md` (Sektion 12: Testing Strategy)
|
||||||
|
- nginx-proxy-manager Konfiguration erforderlich
|
||||||
|
- Hosts: `deinprojekt.hobbyhimmel.de` (public), `deinprojekt.lan.hobbyhimmel.de` (internal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Release Management
|
||||||
|
|
||||||
|
### Automated Release (EMPFOHLEN)
|
||||||
|
|
||||||
|
**Ein Befehl macht alles:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release # Patch: 1.2.0 → 1.2.1
|
||||||
|
npm run release:minor # Minor: 1.2.0 → 1.3.0
|
||||||
|
npm run release:major # Major: 1.2.0 → 2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert automatisch:**
|
||||||
|
1. ✅ Version in allen package.json erhöht
|
||||||
|
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
|
||||||
|
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
|
||||||
|
4. ✅ Git Commit erstellt
|
||||||
|
5. ✅ Git Tag erstellt
|
||||||
|
6. ✅ Preview anzeigen + Bestätigung
|
||||||
|
|
||||||
|
Dann nur noch:
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel-Workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Features entwickeln mit Conventional Commits:
|
||||||
|
git commit -m "feat: Add user login"
|
||||||
|
git commit -m "fix: Fix button alignment"
|
||||||
|
git commit -m "refactor: Extract ConsentFilter component"
|
||||||
|
|
||||||
|
# Release erstellen:
|
||||||
|
npm run release:minor
|
||||||
|
|
||||||
|
# Preview wird angezeigt, dann [Y] drücken
|
||||||
|
# Push:
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### CHANGELOG wird automatisch generiert!
|
||||||
|
|
||||||
|
Das Release-Script (`scripts/release.sh`) gruppiert deine Commits nach Typ:
|
||||||
|
- `feat:` → ✨ Features
|
||||||
|
- `fix:` → 🐛 Fixes
|
||||||
|
- `refactor:` → ♻️ Refactoring
|
||||||
|
- `chore:` → 🔧 Chores
|
||||||
|
- `docs:` → 📚 Documentation
|
||||||
|
|
||||||
|
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
|
||||||
|
|
||||||
|
### Manuelle Scripts (falls nötig)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Version nur synchronisieren (ohne Bump):
|
||||||
|
./scripts/sync-version.sh
|
||||||
|
|
||||||
|
# Version manuell bumpen:
|
||||||
|
./scripts/bump-version.sh patch # oder minor/major
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version-Synchronisation:**
|
||||||
|
- Single Source of Truth: `frontend/package.json`
|
||||||
|
- Wird synchronisiert zu: `backend/package.json`, `Footer.js`, `generate-openapi.js`, Docker Images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Nützliche Befehle
|
## Nützliche Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
144
README.md
144
README.md
|
|
@ -5,6 +5,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Multi-Image Upload**: Upload multiple images at once with batch processing
|
**Multi-Image Upload**: Upload multiple images at once with batch processing
|
||||||
|
**Telegram Notifications**: 🆕 Real-time notifications for uploads, consent changes, deletions, and daily warnings
|
||||||
**Social Media Consent Management**: 🆕 GDPR-compliant consent system for workshop display and social media publishing
|
**Social Media Consent Management**: 🆕 GDPR-compliant consent system for workshop display and social media publishing
|
||||||
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
|
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
|
||||||
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
|
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
|
||||||
|
|
@ -20,83 +21,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
|
||||||
## What's New
|
## What's New
|
||||||
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
|
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
|
||||||
|
|
||||||
### 🆕 Latest Features (November 2025)
|
See the [CHANGELOG](CHANGELOG.md) for a detailed list of improvements and new features.
|
||||||
|
|
||||||
- **🧪 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 Session Authentication** (Nov 16):
|
|
||||||
- Server-managed HTTP sessions for all admin/system endpoints
|
|
||||||
- CSRF protection on every mutating request via `X-CSRF-Token`
|
|
||||||
- Secure `ADMIN_SESSION_SECRET` configuration keeps cookies tamper-proof
|
|
||||||
- Protected routes: `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
|
|
||||||
- Session-aware moderation UI with login + first-admin setup wizard
|
|
||||||
- Complete authentication documentation in `AUTHENTICATION.md`
|
|
||||||
|
|
||||||
- **📋 API Route Documentation** (Nov 16):
|
|
||||||
- Single Source of Truth: `backend/src/routes/routeMappings.js`
|
|
||||||
- Comprehensive route overview in `backend/src/routes/README.md`
|
|
||||||
- Critical Express routing order documented (specific before generic)
|
|
||||||
- Frontend-ready route reference with authentication requirements
|
|
||||||
- OpenAPI specification auto-generation integrated
|
|
||||||
|
|
||||||
- **🔐 Social Media Consent Management** (Phase 1 Complete - Nov 9-10):
|
|
||||||
- GDPR-compliant consent system for image usage
|
|
||||||
- Mandatory workshop display consent (no upload without approval)
|
|
||||||
- Optional per-platform consents (Facebook, Instagram, TikTok)
|
|
||||||
- Consent badges and filtering in moderation panel
|
|
||||||
- CSV/JSON export for legal documentation
|
|
||||||
- Group ID tracking for consent withdrawal requests
|
|
||||||
- **🔑 Self-Service Management Portal** (Phase 2 Complete - Nov 11-15):
|
|
||||||
- Secure UUID-based management tokens for user self-service
|
|
||||||
- Frontend portal at `/manage/:token` for consent management
|
|
||||||
- Revoke/restore consents for workshop and social media
|
|
||||||
- Edit metadata (title, description) after upload
|
|
||||||
- Add/delete images after upload (with moderation re-approval)
|
|
||||||
- Complete group deletion with audit trail
|
|
||||||
- IP-based rate limiting (10 requests/hour)
|
|
||||||
- Brute-force protection (20 failed attempts → 24h ban)
|
|
||||||
- Management audit log for security tracking
|
|
||||||
- **🎨 Modular UI Architecture** (Nov 15):
|
|
||||||
- Reusable components: ConsentManager, GroupMetadataEditor, ImageDescriptionManager
|
|
||||||
- Multi-mode support: upload/edit/moderate modes for maximum reusability
|
|
||||||
- Code reduction: 62% in ModerationGroupImagesPage (281→107 lines)
|
|
||||||
- Consistent design: HTML buttons, Paper boxes, Material-UI Alerts
|
|
||||||
- Individual save/discard per component section
|
|
||||||
- Zero code duplication between pages
|
|
||||||
- **<EFBFBD> Slideshow Optimization**: Intelligent image preloading eliminates loading delays and duplicate images
|
|
||||||
- **📅 Chronological Display**: Slideshows now play in chronological order (year → upload date)
|
|
||||||
- **Automatic Cleanup**: Unapproved groups are automatically deleted after 7 days
|
|
||||||
- **Deletion Log**: Complete audit trail with statistics (groups, images, storage freed)
|
|
||||||
- **Countdown Display**: Visual indicator showing days until automatic deletion
|
|
||||||
- **Approval Feedback**: SweetAlert2 notifications for moderation actions
|
|
||||||
- **Manual Cleanup Trigger**: Admin API endpoints for testing and manual cleanup
|
|
||||||
- **Image Descriptions**: Add optional descriptions to individual images (max 200 characters)
|
|
||||||
- **Edit Mode**: Edit descriptions for uploaded images in upload preview and moderation interface
|
|
||||||
- **Slideshow Display**: Image descriptions shown as overlays during slideshow presentation
|
|
||||||
- **Public Display**: Descriptions visible in public group views and galleries
|
|
||||||
|
|
||||||
### Previous Features (October 2025)
|
|
||||||
- **Drag-and-Drop Image Reordering**: Admins can now reorder images using intuitive drag-and-drop
|
|
||||||
- **Touch-Friendly Interface**: Mobile-optimized controls with always-visible drag handles
|
|
||||||
- **Slideshow Integration**: Custom image order automatically applies to slideshow mode
|
|
||||||
- **Optimistic UI Updates**: Immediate visual feedback with error recovery
|
|
||||||
- **Comprehensive Admin Panel**: Dedicated moderation interface for content curation
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
- Multi-image batch upload with progress tracking
|
|
||||||
- Automatic slideshow presentation mode
|
|
||||||
- Image grouping with descriptions and metadata
|
|
||||||
- Random slideshow rotation with custom ordering support
|
|
||||||
- Keyboard navigation support (Slideshow: Space/Arrow keys, Escape to exit)
|
|
||||||
- Mobile-responsive design with touch-first interactions
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -251,31 +176,31 @@ The application automatically generates optimized preview thumbnails for all upl
|
||||||
|
|
||||||
## Docker Structure
|
## Docker Structure
|
||||||
|
|
||||||
The application uses separate Docker configurations for development and production:
|
The application uses separate Docker configurations for development and production with **simplified environment variable management**:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker/
|
docker/
|
||||||
├── .env.backend.example # Backend environment variables documentation
|
├── .env.backend.example # Backend environment variables documentation
|
||||||
├── .env.frontend.example # Frontend environment variables documentation
|
├── .env.frontend.example # Frontend environment variables documentation
|
||||||
├── dev/ # Development environment
|
├── dev/ # Development environment
|
||||||
│ ├── docker-compose.yml # Development services configuration
|
│ ├── .env # 🆕 Central dev secrets (gitignored)
|
||||||
|
│ ├── .env.example # Dev environment template
|
||||||
|
│ ├── docker-compose.yml # All ENV vars defined here
|
||||||
│ ├── backend/
|
│ ├── backend/
|
||||||
│ │ ├── config/.env # Development backend configuration
|
|
||||||
│ │ └── Dockerfile # Development backend container
|
│ │ └── Dockerfile # Development backend container
|
||||||
│ └── frontend/
|
│ └── frontend/
|
||||||
│ ├── config/.env # Development frontend configuration
|
│ ├── config/env.sh # Generates window._env_ from ENV
|
||||||
│ ├── config/env.sh # Runtime configuration script
|
|
||||||
│ ├── Dockerfile # Development frontend container
|
│ ├── Dockerfile # Development frontend container
|
||||||
│ ├── nginx.conf # Development nginx configuration
|
│ ├── nginx.conf # Development nginx configuration
|
||||||
│ └── start.sh # Development startup script
|
│ └── start.sh # Development startup script
|
||||||
└── prod/ # Production environment
|
└── prod/ # Production environment
|
||||||
├── docker-compose.yml # Production services configuration
|
├── .env # 🆕 Central prod secrets (gitignored)
|
||||||
|
├── .env.example # Production environment template
|
||||||
|
├── docker-compose.yml # All ENV vars defined here
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── config/.env # Production backend configuration
|
|
||||||
│ └── Dockerfile # Production backend container
|
│ └── Dockerfile # Production backend container
|
||||||
└── frontend/
|
└── frontend/
|
||||||
├── config/.env # Production frontend configuration
|
├── config/env.sh # Generates window._env_ from ENV
|
||||||
├── config/env.sh # Runtime configuration script
|
|
||||||
├── config/htpasswd # HTTP Basic Auth credentials
|
├── config/htpasswd # HTTP Basic Auth credentials
|
||||||
├── Dockerfile # Production frontend container
|
├── Dockerfile # Production frontend container
|
||||||
└── nginx.conf # Production nginx configuration
|
└── nginx.conf # Production nginx configuration
|
||||||
|
|
@ -283,6 +208,20 @@ docker/
|
||||||
|
|
||||||
### Environment Configuration
|
### Environment Configuration
|
||||||
|
|
||||||
|
**🆕 Simplified ENV Structure (Nov 2025):**
|
||||||
|
- **2 central `.env` files** (down from 16 files!)
|
||||||
|
- `docker/dev/.env` - All development secrets
|
||||||
|
- `docker/prod/.env` - All production secrets
|
||||||
|
- **docker-compose.yml** - All environment variables defined in `environment:` sections
|
||||||
|
- **No .env files in Docker images** - All configuration via docker-compose
|
||||||
|
- **Frontend env.sh** - Generates `window._env_` JavaScript object from ENV variables at runtime
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Docker Compose automatically reads `.env` from the same directory
|
||||||
|
2. Variables are injected into containers via `environment:` sections using `${VAR}` placeholders
|
||||||
|
3. Frontend `env.sh` script reads ENV variables and generates JavaScript config at container startup
|
||||||
|
4. Secrets stay in gitignored `.env` files, never in code or images
|
||||||
|
|
||||||
- **Development**: Uses `docker/dev/` configuration with live reloading
|
- **Development**: Uses `docker/dev/` configuration with live reloading
|
||||||
- **Production**: Uses `docker/prod/` configuration with optimized builds
|
- **Production**: Uses `docker/prod/` configuration with optimized builds
|
||||||
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
|
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
|
||||||
|
|
@ -580,12 +519,41 @@ The application includes comprehensive testing tools for the automatic cleanup f
|
||||||
For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
|
For detailed testing instructions, see: [`tests/TESTING-CLEANUP.md`](tests/TESTING-CLEANUP.md)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
**Simplified ENV Management (Nov 2025):**
|
||||||
|
All environment variables are now managed through **2 central `.env` files** and `docker-compose.yml`:
|
||||||
|
|
||||||
|
**Core Variables:**
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `API_URL` | `http://localhost:5001` | Backend API endpoint |
|
| `API_URL` | `http://localhost:5001` | Backend API endpoint (frontend → backend) |
|
||||||
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
|
| `PUBLIC_HOST` | `public.test.local` | Public upload subdomain (no admin access) |
|
||||||
|
| `INTERNAL_HOST` | `internal.test.local` | Internal admin subdomain (full access) |
|
||||||
|
| `ADMIN_SESSION_SECRET` | - | Secret for admin session cookies (required) |
|
||||||
|
|
||||||
|
**Telegram Notifications (Optional):**
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TELEGRAM_ENABLED` | `false` | Enable/disable Telegram notifications |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | - | Telegram Bot API token (from @BotFather) |
|
||||||
|
| `TELEGRAM_CHAT_ID` | - | Telegram chat/group ID for notifications |
|
||||||
|
| `TELEGRAM_SEND_TEST_ON_START` | `false` | Send test message on service startup (dev only) |
|
||||||
|
|
||||||
|
**Configuration Files:**
|
||||||
|
- `docker/dev/.env` - Development secrets (gitignored)
|
||||||
|
- `docker/prod/.env` - Production secrets (gitignored)
|
||||||
|
- `docker/dev/.env.example` - Development template (committed)
|
||||||
|
- `docker/prod/.env.example` - Production template (committed)
|
||||||
|
|
||||||
|
**How to configure:**
|
||||||
|
1. Copy `.env.example` to `.env` in the respective environment folder
|
||||||
|
2. Edit `.env` and set your secrets (ADMIN_SESSION_SECRET, Telegram tokens, etc.)
|
||||||
|
3. Docker Compose automatically reads `.env` and injects variables into containers
|
||||||
|
4. Never commit `.env` files (already in `.gitignore`)
|
||||||
|
|
||||||
|
**Telegram Setup:** See `scripts/README.telegram.md` for complete configuration guide.
|
||||||
|
|
||||||
### Volume Configuration
|
### Volume Configuration
|
||||||
- **Upload Limits**: 100MB maximum file size for batch uploads
|
- **Upload Limits**: 100MB maximum file size for batch uploads
|
||||||
|
|
|
||||||
2
TODO.md
2
TODO.md
|
|
@ -101,7 +101,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
|
||||||
[x] 🎨 Drag & Drop Reihenfolge ändern
|
[x] 🎨 Drag & Drop Reihenfolge ändern
|
||||||
[x] 📊 Upload-Progress mit Details
|
[x] 📊 Upload-Progress mit Details
|
||||||
[x] 🖼️ Thumbnail-Navigation in Slideshow
|
[x] 🖼️ Thumbnail-Navigation in Slideshow
|
||||||
[ ] 🔄 Batch-Operations (alle entfernen, etc.)
|
|
||||||
|
|
||||||
### Future Features
|
### Future Features
|
||||||
- 👤 User-Management
|
- 👤 User-Management
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Project Image Uploader API",
|
"title": "Project Image Uploader API",
|
||||||
"version": "1.0.0",
|
"version": "2.0.1",
|
||||||
"description": "Auto-generated OpenAPI spec with correct mount prefixes"
|
"description": "Auto-generated OpenAPI spec with correct mount prefixes"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
|
|
@ -39,6 +39,9 @@
|
||||||
{
|
{
|
||||||
"name": "Admin - Cleanup"
|
"name": "Admin - Cleanup"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin - Telegram"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Admin - Monitoring"
|
"name": "Admin - Monitoring"
|
||||||
},
|
},
|
||||||
|
|
@ -322,6 +325,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Server error during upload"
|
"description": "Server error during upload"
|
||||||
}
|
}
|
||||||
|
|
@ -382,6 +388,15 @@
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"example": "any"
|
"example": "any"
|
||||||
|
},
|
||||||
|
"year": {
|
||||||
|
"example": "any"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"example": "any"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"example": "any"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1055,22 +1070,38 @@
|
||||||
},
|
},
|
||||||
"/api/manage/{token}/reorder": {
|
"/api/manage/{token}/reorder": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "",
|
"tags": [
|
||||||
|
"Management Portal"
|
||||||
|
],
|
||||||
|
"summary": "Reorder images in group",
|
||||||
|
"description": "Reorder images within the managed group (token-based access)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "token",
|
"name": "token",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"description": "Management token (UUID v4)",
|
||||||
|
"example": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"imageIds": {
|
"imageIds": {
|
||||||
"example": "any"
|
"type": "array",
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1078,13 +1109,29 @@
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK"
|
"description": "Images reordered successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"updatedCount": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml": {
|
||||||
|
"name": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request"
|
"description": "Invalid token format or imageIds"
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Not Found"
|
"description": "Token not found or group deleted"
|
||||||
},
|
},
|
||||||
"429": {
|
"429": {
|
||||||
"description": "Too Many Requests"
|
"description": "Too Many Requests"
|
||||||
|
|
@ -1148,25 +1195,46 @@
|
||||||
},
|
},
|
||||||
"/api/admin/groups/{groupId}/consents": {
|
"/api/admin/groups/{groupId}/consents": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "",
|
"tags": [
|
||||||
|
"Consent Management"
|
||||||
|
],
|
||||||
|
"summary": "Save or update consents for a group",
|
||||||
|
"description": "Store workshop consent and social media consents for a specific group",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "groupId",
|
"name": "groupId",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"description": "Group ID",
|
||||||
|
"example": "abc123def456"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"workshopConsent": {
|
"workshopConsent": {
|
||||||
"example": "any"
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
},
|
},
|
||||||
"socialMediaConsents": {
|
"socialMediaConsents": {
|
||||||
"example": "any"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"platformId": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"consented": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1174,10 +1242,26 @@
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK"
|
"description": "Consents saved successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Consents saved successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml": {
|
||||||
|
"name": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request"
|
"description": "Invalid request data"
|
||||||
},
|
},
|
||||||
"403": {
|
"403": {
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
|
|
@ -1714,6 +1798,46 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/telegram/warning": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin - Telegram"
|
||||||
|
],
|
||||||
|
"summary": "Manually trigger Telegram deletion warning",
|
||||||
|
"description": "Sends deletion warning to Telegram for testing (normally runs daily at 09:00)",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Warning sent successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"groupsWarned": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Deletion warning sent for 2 groups"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml": {
|
||||||
|
"name": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/cleanup/preview": {
|
"/api/admin/cleanup/preview": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -2570,6 +2694,96 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/groups/{groupId}/reorder": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Admin - Groups Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Reorder images in a group",
|
||||||
|
"description": "Updates the display order of images within a group",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "groupId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"type": "string",
|
||||||
|
"description": "Group ID",
|
||||||
|
"example": "abc123def456"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Images reordered successfully",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Image order updated successfully"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"updatedImages": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xml": {
|
||||||
|
"name": "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid imageIds parameter"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Group not found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"imageIds"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"imageIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"description": "Array of image IDs in new order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/{groupId}/reorder": {
|
"/api/admin/{groupId}/reorder": {
|
||||||
"put": {
|
"put": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,9 @@ module.exports = {
|
||||||
// Run tests serially to avoid DB conflicts
|
// Run tests serially to avoid DB conflicts
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
// Force exit after tests complete
|
// Force exit after tests complete
|
||||||
forceExit: true
|
forceExit: true,
|
||||||
|
// Transform ESM modules in node_modules
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(uuid)/)'
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"find-remove": "^2.0.3",
|
"find-remove": "^2.0.3",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"shortid": "^2.2.16",
|
"shortid": "^2.2.16",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Migration 009: Add source tracking to audit log
|
||||||
|
-- Adds source_host and source_type columns to management_audit_log
|
||||||
|
|
||||||
|
-- Add source_host column (stores the hostname from which request originated)
|
||||||
|
ALTER TABLE management_audit_log ADD COLUMN source_host TEXT;
|
||||||
|
|
||||||
|
-- Add source_type column (stores 'public' or 'internal')
|
||||||
|
ALTER TABLE management_audit_log ADD COLUMN source_type TEXT;
|
||||||
|
|
||||||
|
-- Create index for filtering by source_type
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_source_type ON management_audit_log(source_type);
|
||||||
|
|
@ -16,7 +16,7 @@ const endpointsFiles = routerMappings.map(r => path.join(routesDir, r.file));
|
||||||
const doc = {
|
const doc = {
|
||||||
info: {
|
info: {
|
||||||
title: 'Project Image Uploader API',
|
title: 'Project Image Uploader API',
|
||||||
version: '1.0.0',
|
version: '2.0.1',
|
||||||
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
|
description: 'Auto-generated OpenAPI spec with correct mount prefixes'
|
||||||
},
|
},
|
||||||
host: 'localhost:5001',
|
host: 'localhost:5001',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ const auditLogMiddleware = (req, res, next) => {
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
|
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
|
||||||
const userAgent = req.get('user-agent') || 'unknown';
|
const userAgent = req.get('user-agent') || 'unknown';
|
||||||
const managementToken = req.params.token || null;
|
const managementToken = req.params.token || null;
|
||||||
|
const sourceHost = req.get('x-forwarded-host') || req.get('host') || 'unknown';
|
||||||
|
const sourceType = req.requestSource || 'unknown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log-Funktion für Controllers
|
* Log-Funktion für Controllers
|
||||||
|
|
@ -33,7 +35,9 @@ const auditLogMiddleware = (req, res, next) => {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
requestData
|
requestData,
|
||||||
|
sourceHost,
|
||||||
|
sourceType
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to write audit log:', error);
|
console.error('Failed to write audit log:', error);
|
||||||
|
|
|
||||||
114
backend/src/middlewares/hostGate.js
Normal file
114
backend/src/middlewares/hostGate.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* Host Gate Middleware
|
||||||
|
* Blockiert geschützte API-Routen für public Host
|
||||||
|
* Erlaubt nur Upload + Management für public Host
|
||||||
|
*
|
||||||
|
* Erkennt Host via X-Forwarded-Host (nginx-proxy-manager) oder Host Header
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUBLIC_HOST = process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
|
||||||
|
const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
|
||||||
|
const ENABLE_HOST_RESTRICTION = process.env.ENABLE_HOST_RESTRICTION !== 'false';
|
||||||
|
|
||||||
|
// Debug: Log configuration on module load (development only)
|
||||||
|
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
|
||||||
|
console.log('🔧 hostGate config:', { PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes die NUR für internal Host erlaubt sind
|
||||||
|
const INTERNAL_ONLY_ROUTES = [
|
||||||
|
'/api/admin',
|
||||||
|
'/api/groups',
|
||||||
|
'/api/slideshow',
|
||||||
|
'/api/migration',
|
||||||
|
'/api/moderation',
|
||||||
|
'/api/reorder',
|
||||||
|
'/api/batch-upload',
|
||||||
|
'/api/social-media',
|
||||||
|
'/api/auth/login', // Admin Login nur internal
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/api/auth/session'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes die für public Host erlaubt sind
|
||||||
|
const PUBLIC_ALLOWED_ROUTES = [
|
||||||
|
'/api/upload',
|
||||||
|
'/api/manage',
|
||||||
|
'/api/previews',
|
||||||
|
'/api/consent',
|
||||||
|
'/api/social-media/platforms' // Nur Plattformen lesen (für Consent-Badges im UUID Management)
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: Host-basierte Zugriffskontrolle
|
||||||
|
* @param {Object} req - Express Request
|
||||||
|
* @param {Object} res - Express Response
|
||||||
|
* @param {Function} next - Next Middleware
|
||||||
|
*/
|
||||||
|
const hostGate = (req, res, next) => {
|
||||||
|
// Feature disabled only when explicitly set to false OR in test environment without explicit enable
|
||||||
|
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||||
|
const explicitlyEnabled = process.env.ENABLE_HOST_RESTRICTION === 'true';
|
||||||
|
const explicitlyDisabled = process.env.ENABLE_HOST_RESTRICTION === 'false';
|
||||||
|
|
||||||
|
// Skip restriction if:
|
||||||
|
// - Explicitly disabled, OR
|
||||||
|
// - Test environment AND not explicitly enabled
|
||||||
|
if (explicitlyDisabled || (isTestEnv && !explicitlyEnabled)) {
|
||||||
|
req.isPublicHost = false;
|
||||||
|
req.isInternalHost = true;
|
||||||
|
req.requestSource = 'internal';
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get host from X-Forwarded-Host (nginx-proxy-manager) or Host header
|
||||||
|
const forwardedHost = req.get('x-forwarded-host');
|
||||||
|
const hostHeader = req.get('host');
|
||||||
|
const host = forwardedHost || hostHeader || '';
|
||||||
|
const hostname = host.split(':')[0]; // Remove port if present
|
||||||
|
|
||||||
|
// Determine if request is from public or internal host
|
||||||
|
req.isPublicHost = hostname === PUBLIC_HOST;
|
||||||
|
req.isInternalHost = hostname === INTERNAL_HOST || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
// Log host detection for debugging
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log(`🔍 Host Detection: ${hostname} → ${req.isPublicHost ? 'PUBLIC' : 'INTERNAL'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If public host, check if route is allowed
|
||||||
|
if (req.isPublicHost) {
|
||||||
|
const path = req.path;
|
||||||
|
|
||||||
|
// Check if explicitly allowed (z.B. /api/social-media/platforms)
|
||||||
|
const isExplicitlyAllowed = PUBLIC_ALLOWED_ROUTES.some(route =>
|
||||||
|
path === route || path.startsWith(route + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isExplicitlyAllowed) {
|
||||||
|
// Erlaubt - kein Block
|
||||||
|
req.requestSource = 'public';
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route is internal-only
|
||||||
|
const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route =>
|
||||||
|
path.startsWith(route)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInternalOnly) {
|
||||||
|
console.warn(`🚫 Public host blocked access to: ${path} (Host: ${hostname})`);
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Not available on public host',
|
||||||
|
message: 'This endpoint is only available on the internal network'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request source context for audit logging
|
||||||
|
req.requestSource = req.isPublicHost ? 'public' : 'internal';
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = hostGate;
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require("express");
|
||||||
const fileUpload = require("express-fileupload");
|
const fileUpload = require("express-fileupload");
|
||||||
const cors = require("./cors");
|
const cors = require("./cors");
|
||||||
const session = require("./session");
|
const session = require("./session");
|
||||||
|
const hostGate = require("./hostGate");
|
||||||
|
|
||||||
const applyMiddlewares = (app) => {
|
const applyMiddlewares = (app) => {
|
||||||
app.use(fileUpload());
|
app.use(fileUpload());
|
||||||
|
|
@ -9,6 +10,8 @@ const applyMiddlewares = (app) => {
|
||||||
app.use(session);
|
app.use(session);
|
||||||
// JSON Parser für PATCH/POST Requests
|
// JSON Parser für PATCH/POST Requests
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
// Host Gate: Blockiert geschützte Routen für public Host
|
||||||
|
app.use(hostGate);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { applyMiddlewares };
|
module.exports = { applyMiddlewares };
|
||||||
|
|
@ -19,6 +19,15 @@ const RATE_LIMIT = {
|
||||||
BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden
|
BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Public Upload Rate Limiting (strengere Limits für öffentliche Uploads)
|
||||||
|
const PUBLIC_UPLOAD_LIMIT = {
|
||||||
|
MAX_UPLOADS_PER_HOUR: parseInt(process.env.PUBLIC_UPLOAD_RATE_LIMIT || '20', 10),
|
||||||
|
WINDOW_MS: parseInt(process.env.PUBLIC_UPLOAD_RATE_WINDOW || '3600000', 10) // 1 Stunde
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-Memory Storage für Public Upload Rate-Limiting
|
||||||
|
const publicUploadCounts = new Map(); // IP -> { count, resetTime }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrahiere Client-IP aus Request
|
* Extrahiere Client-IP aus Request
|
||||||
*/
|
*/
|
||||||
|
|
@ -169,13 +178,63 @@ function getStatistics() {
|
||||||
reason: info.reason,
|
reason: info.reason,
|
||||||
blockedUntil: new Date(info.blockedUntil).toISOString(),
|
blockedUntil: new Date(info.blockedUntil).toISOString(),
|
||||||
failedAttempts: info.failedAttempts
|
failedAttempts: info.failedAttempts
|
||||||
}))
|
})),
|
||||||
|
publicUploadActiveIPs: publicUploadCounts.size
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Upload Rate Limiter Middleware
|
||||||
|
* Strengere Limits für öffentliche Uploads (20 pro Stunde pro IP)
|
||||||
|
* Wird nur auf public Host angewendet
|
||||||
|
*/
|
||||||
|
function publicUploadLimiter(req, res, next) {
|
||||||
|
// Skip wenn nicht public Host oder Feature disabled
|
||||||
|
if (!req.isPublicHost || process.env.NODE_ENV === 'test') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Hole oder erstelle Upload-Counter für IP
|
||||||
|
let uploadInfo = publicUploadCounts.get(clientIP);
|
||||||
|
|
||||||
|
if (!uploadInfo || now > uploadInfo.resetTime) {
|
||||||
|
// Neues Zeitfenster
|
||||||
|
uploadInfo = {
|
||||||
|
count: 0,
|
||||||
|
resetTime: now + PUBLIC_UPLOAD_LIMIT.WINDOW_MS
|
||||||
|
};
|
||||||
|
publicUploadCounts.set(clientIP, uploadInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Upload-Limit
|
||||||
|
if (uploadInfo.count >= PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR) {
|
||||||
|
const resetIn = Math.ceil((uploadInfo.resetTime - now) / 1000 / 60);
|
||||||
|
console.warn(`🚫 Public upload limit exceeded for IP ${clientIP} (${uploadInfo.count}/${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR})`);
|
||||||
|
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Upload limit exceeded',
|
||||||
|
message: `You have reached the maximum of ${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR} uploads per hour. Please try again in ${resetIn} minutes.`,
|
||||||
|
limit: PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR,
|
||||||
|
resetIn: resetIn
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erhöhe Upload-Counter
|
||||||
|
uploadInfo.count++;
|
||||||
|
publicUploadCounts.set(clientIP, uploadInfo);
|
||||||
|
|
||||||
|
// Request durchlassen
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rateLimitMiddleware,
|
rateLimitMiddleware,
|
||||||
recordFailedTokenValidation,
|
recordFailedTokenValidation,
|
||||||
cleanupExpiredEntries,
|
cleanupExpiredEntries,
|
||||||
getStatistics
|
getStatistics,
|
||||||
|
publicUploadLimiter
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ class ManagementAuditLogRepository {
|
||||||
* @param {string} logData.ipAddress - IP-Adresse
|
* @param {string} logData.ipAddress - IP-Adresse
|
||||||
* @param {string} logData.userAgent - User-Agent
|
* @param {string} logData.userAgent - User-Agent
|
||||||
* @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert)
|
* @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert)
|
||||||
|
* @param {string} logData.sourceHost - Source Host (public/internal)
|
||||||
|
* @param {string} logData.sourceType - Source Type (public/internal)
|
||||||
* @returns {Promise<number>} ID des Log-Eintrags
|
* @returns {Promise<number>} ID des Log-Eintrags
|
||||||
*/
|
*/
|
||||||
async logAction(logData) {
|
async logAction(logData) {
|
||||||
|
|
@ -34,22 +36,50 @@ class ManagementAuditLogRepository {
|
||||||
managementToken: undefined // Token nie loggen
|
managementToken: undefined // Token nie loggen
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
const query = `
|
// Prüfe ob Spalten source_host und source_type existieren
|
||||||
INSERT INTO management_audit_log
|
const tableInfo = await dbManager.all(`PRAGMA table_info(management_audit_log)`);
|
||||||
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
|
const hasSourceColumns = tableInfo.some(col => col.name === 'source_host');
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`;
|
let query, params;
|
||||||
|
|
||||||
const result = await dbManager.run(query, [
|
if (hasSourceColumns) {
|
||||||
logData.groupId || null,
|
query = `
|
||||||
maskedToken,
|
INSERT INTO management_audit_log
|
||||||
logData.action,
|
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data, source_host, source_type)
|
||||||
logData.success ? 1 : 0,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
logData.errorMessage || null,
|
`;
|
||||||
logData.ipAddress || null,
|
params = [
|
||||||
logData.userAgent || null,
|
logData.groupId || null,
|
||||||
sanitizedData ? JSON.stringify(sanitizedData) : null
|
maskedToken,
|
||||||
]);
|
logData.action,
|
||||||
|
logData.success ? 1 : 0,
|
||||||
|
logData.errorMessage || null,
|
||||||
|
logData.ipAddress || null,
|
||||||
|
logData.userAgent || null,
|
||||||
|
sanitizedData ? JSON.stringify(sanitizedData) : null,
|
||||||
|
logData.sourceHost || null,
|
||||||
|
logData.sourceType || null
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Fallback für alte DB-Schemas ohne source_host/source_type
|
||||||
|
query = `
|
||||||
|
INSERT INTO management_audit_log
|
||||||
|
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
params = [
|
||||||
|
logData.groupId || null,
|
||||||
|
maskedToken,
|
||||||
|
logData.action,
|
||||||
|
logData.success ? 1 : 0,
|
||||||
|
logData.errorMessage || null,
|
||||||
|
logData.ipAddress || null,
|
||||||
|
logData.userAgent || null,
|
||||||
|
sanitizedData ? JSON.stringify(sanitizedData) : null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dbManager.run(query, params);
|
||||||
|
|
||||||
return result.lastID;
|
return result.lastID;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,46 @@ router.post('/cleanup/trigger', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/telegram/warning', async (req, res) => {
|
||||||
|
/*
|
||||||
|
#swagger.tags = ['Admin - Telegram']
|
||||||
|
#swagger.summary = 'Manually trigger Telegram deletion warning'
|
||||||
|
#swagger.description = 'Sends deletion warning to Telegram for testing (normally runs daily at 09:00)'
|
||||||
|
#swagger.responses[200] = {
|
||||||
|
description: 'Warning sent successfully',
|
||||||
|
schema: {
|
||||||
|
success: true,
|
||||||
|
groupsWarned: 2,
|
||||||
|
message: 'Deletion warning sent for 2 groups'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const schedulerService = req.app.get('schedulerService');
|
||||||
|
|
||||||
|
if (!schedulerService) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Scheduler service not available'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await schedulerService.triggerTelegramWarningNow();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
groupsWarned: result.groupCount,
|
||||||
|
message: result.message
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Admin API] Error triggering Telegram warning:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/cleanup/preview', async (req, res) => {
|
router.get('/cleanup/preview', async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Admin - Cleanup']
|
#swagger.tags = ['Admin - Cleanup']
|
||||||
|
|
@ -978,6 +1018,120 @@ router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put('/groups/:groupId/reorder', async (req, res) => {
|
||||||
|
/*
|
||||||
|
#swagger.tags = ['Admin - Groups Moderation']
|
||||||
|
#swagger.summary = 'Reorder images in a group'
|
||||||
|
#swagger.description = 'Updates the display order of images within a group'
|
||||||
|
#swagger.parameters['groupId'] = {
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group ID',
|
||||||
|
example: 'abc123def456'
|
||||||
|
}
|
||||||
|
#swagger.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['imageIds'],
|
||||||
|
properties: {
|
||||||
|
imageIds: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'integer' },
|
||||||
|
example: [5, 3, 1, 2, 4],
|
||||||
|
description: 'Array of image IDs in new order'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#swagger.responses[200] = {
|
||||||
|
description: 'Images reordered successfully',
|
||||||
|
schema: {
|
||||||
|
success: true,
|
||||||
|
message: 'Image order updated successfully',
|
||||||
|
data: {
|
||||||
|
updatedImages: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#swagger.responses[400] = {
|
||||||
|
description: 'Invalid imageIds parameter'
|
||||||
|
}
|
||||||
|
#swagger.responses[404] = {
|
||||||
|
description: 'Group not found'
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const { groupId } = req.params;
|
||||||
|
const { imageIds } = req.body;
|
||||||
|
|
||||||
|
// Validate imageIds
|
||||||
|
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'imageIds array is required and cannot be empty'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all imageIds are numbers
|
||||||
|
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group exists
|
||||||
|
const groupData = await GroupRepository.getGroupById(groupId);
|
||||||
|
if (!groupData) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Group not found',
|
||||||
|
message: `Gruppe mit ID ${groupId} wurde nicht gefunden`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reorder using GroupRepository
|
||||||
|
const result = await GroupRepository.updateImageOrder(groupId, imageIds);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Image order updated successfully',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ADMIN] Error reordering images for group ${req.params.groupId}:`, error.message);
|
||||||
|
|
||||||
|
// Handle specific errors
|
||||||
|
if (error.message.includes('not found')) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Group or images not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.includes('mismatch')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to reorder images',
|
||||||
|
message: 'Fehler beim Sortieren der Bilder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/groups/:groupId', async (req, res) => {
|
router.delete('/groups/:groupId', async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Admin - Groups Moderation']
|
#swagger.tags = ['Admin - Groups Moderation']
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ const UploadGroup = require('../models/uploadGroup');
|
||||||
const groupRepository = require('../repositories/GroupRepository');
|
const groupRepository = require('../repositories/GroupRepository');
|
||||||
const dbManager = require('../database/DatabaseManager');
|
const dbManager = require('../database/DatabaseManager');
|
||||||
const ImagePreviewService = require('../services/ImagePreviewService');
|
const ImagePreviewService = require('../services/ImagePreviewService');
|
||||||
|
const TelegramNotificationService = require('../services/TelegramNotificationService');
|
||||||
|
|
||||||
|
// Singleton-Instanz des Telegram Service
|
||||||
|
const telegramService = new TelegramNotificationService();
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -117,6 +121,12 @@ router.post('/upload/batch', async (req, res) => {
|
||||||
consents = {};
|
consents = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge separate form fields into metadata (backwards compatibility)
|
||||||
|
if (req.body.year) metadata.year = parseInt(req.body.year);
|
||||||
|
if (req.body.title) metadata.title = req.body.title;
|
||||||
|
if (req.body.name) metadata.name = req.body.name;
|
||||||
|
if (req.body.description) metadata.description = req.body.description;
|
||||||
|
|
||||||
// Validiere Workshop Consent (Pflichtfeld)
|
// Validiere Workshop Consent (Pflichtfeld)
|
||||||
if (!consents.workshopConsent) {
|
if (!consents.workshopConsent) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -229,6 +239,22 @@ router.post('/upload/batch', async (req, res) => {
|
||||||
|
|
||||||
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
|
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);
|
||||||
|
|
||||||
|
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
||||||
|
if (telegramService.isAvailable()) {
|
||||||
|
telegramService.sendUploadNotification({
|
||||||
|
name: group.name,
|
||||||
|
year: group.year,
|
||||||
|
title: group.title,
|
||||||
|
imageCount: files.length,
|
||||||
|
workshopConsent: consents.workshopConsent,
|
||||||
|
socialMediaConsents: consents.socialMediaConsents || [],
|
||||||
|
token: createResult.managementToken
|
||||||
|
}).catch(err => {
|
||||||
|
// Fehler loggen, aber Upload nicht fehlschlagen lassen
|
||||||
|
console.error('[Telegram] Upload notification failed:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Erfolgreiche Antwort mit Management-Token
|
// Erfolgreiche Antwort mit Management-Token
|
||||||
res.json({
|
res.json({
|
||||||
groupId: group.groupId,
|
groupId: group.groupId,
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,37 @@ router.get('/social-media/platforms', async (req, res) => {
|
||||||
// Group Consents
|
// Group Consents
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /groups/:groupId/consents
|
|
||||||
* Speichere oder aktualisiere Consents für eine Gruppe
|
|
||||||
*
|
|
||||||
* Body: {
|
|
||||||
* workshopConsent: boolean,
|
|
||||||
* socialMediaConsents: [{ platformId: number, consented: boolean }]
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.post('/groups/:groupId/consents', async (req, res) => {
|
router.post('/groups/:groupId/consents', async (req, res) => {
|
||||||
|
/*
|
||||||
|
#swagger.tags = ['Consent Management']
|
||||||
|
#swagger.summary = 'Save or update consents for a group'
|
||||||
|
#swagger.description = 'Store workshop consent and social media consents for a specific group'
|
||||||
|
#swagger.parameters['groupId'] = {
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group ID',
|
||||||
|
example: 'abc123def456'
|
||||||
|
}
|
||||||
|
#swagger.parameters['body'] = {
|
||||||
|
in: 'body',
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: [
|
||||||
|
{ platformId: 1, consented: true },
|
||||||
|
{ platformId: 2, consented: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#swagger.responses[200] = {
|
||||||
|
description: 'Consents saved successfully',
|
||||||
|
schema: { success: true, message: 'Consents saved successfully' }
|
||||||
|
}
|
||||||
|
#swagger.responses[400] = {
|
||||||
|
description: 'Invalid request data'
|
||||||
|
}
|
||||||
|
*/
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const { workshopConsent, socialMediaConsents } = req.body;
|
const { workshopConsent, socialMediaConsents } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ const deletionLogRepository = require('../repositories/DeletionLogRepository');
|
||||||
const dbManager = require('../database/DatabaseManager');
|
const dbManager = require('../database/DatabaseManager');
|
||||||
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
|
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
|
||||||
const auditLogMiddleware = require('../middlewares/auditLog');
|
const auditLogMiddleware = require('../middlewares/auditLog');
|
||||||
|
const TelegramNotificationService = require('../services/TelegramNotificationService');
|
||||||
|
|
||||||
|
// Singleton-Instanz des Telegram Service
|
||||||
|
const telegramService = new TelegramNotificationService();
|
||||||
|
|
||||||
// Apply middleware to all management routes
|
// Apply middleware to all management routes
|
||||||
router.use(rateLimitMiddleware);
|
router.use(rateLimitMiddleware);
|
||||||
|
|
@ -211,6 +215,20 @@ router.put('/:token/consents', async (req, res) => {
|
||||||
[newValue, groupData.groupId]
|
[newValue, groupData.groupId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
||||||
|
if (telegramService.isAvailable()) {
|
||||||
|
telegramService.sendConsentChangeNotification({
|
||||||
|
name: groupData.name,
|
||||||
|
year: groupData.year,
|
||||||
|
title: groupData.title,
|
||||||
|
consentType: 'workshop',
|
||||||
|
action: action,
|
||||||
|
newValue: newValue === 1
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Telegram] Consent change notification failed:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Workshop consent ${action}d successfully`,
|
message: `Workshop consent ${action}d successfully`,
|
||||||
|
|
@ -263,6 +281,26 @@ router.put('/:token/consents', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
||||||
|
if (telegramService.isAvailable()) {
|
||||||
|
// Hole Platform-Name für Benachrichtigung
|
||||||
|
const platform = await dbManager.get(
|
||||||
|
'SELECT platform_name FROM social_media_platforms WHERE id = ?',
|
||||||
|
[platformId]
|
||||||
|
);
|
||||||
|
|
||||||
|
telegramService.sendConsentChangeNotification({
|
||||||
|
name: groupData.name,
|
||||||
|
year: groupData.year,
|
||||||
|
title: groupData.title,
|
||||||
|
consentType: 'social_media',
|
||||||
|
action: action,
|
||||||
|
platform: platform ? platform.platform_name : `Platform ${platformId}`
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Telegram] Consent change notification failed:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Social media consent ${action}d successfully`,
|
message: `Social media consent ${action}d successfully`,
|
||||||
|
|
@ -1007,6 +1045,18 @@ router.delete('/:token', async (req, res) => {
|
||||||
|
|
||||||
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
|
console.log(`✓ Group ${groupId} deleted via management token (${imageCount} images)`);
|
||||||
|
|
||||||
|
// Sende Telegram-Benachrichtigung (async, non-blocking)
|
||||||
|
if (telegramService.isAvailable()) {
|
||||||
|
telegramService.sendGroupDeletedNotification({
|
||||||
|
name: groupData.name,
|
||||||
|
year: groupData.year,
|
||||||
|
title: groupData.title,
|
||||||
|
imageCount: imageCount
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Telegram] Group deletion notification failed:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Group and all associated data deleted successfully',
|
message: 'Group and all associated data deleted successfully',
|
||||||
|
|
@ -1026,18 +1076,36 @@ router.delete('/:token', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/manage/:token/reorder
|
|
||||||
* Reorder images within the managed group (token-based access)
|
|
||||||
*
|
|
||||||
* @param {string} token - Management token (UUID v4)
|
|
||||||
* @param {number[]} imageIds - Array of image IDs in new order
|
|
||||||
* @returns {Object} Success status and updated image count
|
|
||||||
* @throws {400} Invalid token format or imageIds
|
|
||||||
* @throws {404} Token not found or group deleted
|
|
||||||
* @throws {500} Server error
|
|
||||||
*/
|
|
||||||
router.put('/:token/reorder', async (req, res) => {
|
router.put('/:token/reorder', async (req, res) => {
|
||||||
|
/*
|
||||||
|
#swagger.tags = ['Management Portal']
|
||||||
|
#swagger.summary = 'Reorder images in group'
|
||||||
|
#swagger.description = 'Reorder images within the managed group (token-based access)'
|
||||||
|
#swagger.parameters['token'] = {
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
description: 'Management token (UUID v4)',
|
||||||
|
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
}
|
||||||
|
#swagger.parameters['body'] = {
|
||||||
|
in: 'body',
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
imageIds: [1, 3, 2, 4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#swagger.responses[200] = {
|
||||||
|
description: 'Images reordered successfully',
|
||||||
|
schema: { success: true, updatedCount: 4 }
|
||||||
|
}
|
||||||
|
#swagger.responses[400] = {
|
||||||
|
description: 'Invalid token format or imageIds'
|
||||||
|
}
|
||||||
|
#swagger.responses[404] = {
|
||||||
|
description: 'Token not found or group deleted'
|
||||||
|
}
|
||||||
|
*/
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { imageIds } = req.body;
|
const { imageIds } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const path = require('path');
|
||||||
const ImagePreviewService = require('../services/ImagePreviewService');
|
const ImagePreviewService = require('../services/ImagePreviewService');
|
||||||
const groupRepository = require('../repositories/GroupRepository');
|
const groupRepository = require('../repositories/GroupRepository');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { publicUploadLimiter } = require('../middlewares/rateLimiter');
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR)
|
||||||
// Serve preview images via URL /previews but store files under data/previews
|
// Serve preview images via URL /previews but store files under data/previews
|
||||||
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
|
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
|
||||||
|
|
||||||
router.post('/upload', async (req, res) => {
|
router.post('/upload', publicUploadLimiter, async (req, res) => {
|
||||||
/*
|
/*
|
||||||
#swagger.tags = ['Upload']
|
#swagger.tags = ['Upload']
|
||||||
#swagger.summary = 'Upload a single image and create a new group'
|
#swagger.summary = 'Upload a single image and create a new group'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ const path = require('path');
|
||||||
const initiateResources = require('./utils/initiate-resources');
|
const initiateResources = require('./utils/initiate-resources');
|
||||||
const dbManager = require('./database/DatabaseManager');
|
const dbManager = require('./database/DatabaseManager');
|
||||||
const SchedulerService = require('./services/SchedulerService');
|
const SchedulerService = require('./services/SchedulerService');
|
||||||
|
const TelegramNotificationService = require('./services/TelegramNotificationService');
|
||||||
|
|
||||||
|
// Singleton-Instanz des Telegram Service
|
||||||
|
const telegramService = new TelegramNotificationService();
|
||||||
|
|
||||||
// Dev: Swagger UI (mount only in non-production) — require lazily
|
// Dev: Swagger UI (mount only in non-production) — require lazily
|
||||||
let swaggerUi = null;
|
let swaggerUi = null;
|
||||||
|
|
@ -20,6 +24,10 @@ class Server {
|
||||||
constructor(port) {
|
constructor(port) {
|
||||||
this._port = port;
|
this._port = port;
|
||||||
this._app = express();
|
this._app = express();
|
||||||
|
const trustProxyHops = Number.parseInt(process.env.TRUST_PROXY_HOPS ?? '1', 10);
|
||||||
|
if (!Number.isNaN(trustProxyHops) && trustProxyHops > 0) {
|
||||||
|
this._app.set('trust proxy', trustProxyHops);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateOpenApiSpecIfNeeded() {
|
async generateOpenApiSpecIfNeeded() {
|
||||||
|
|
@ -74,8 +82,19 @@ class Server {
|
||||||
console.log(`✅ Server läuft auf Port ${this._port}`);
|
console.log(`✅ Server läuft auf Port ${this._port}`);
|
||||||
console.log(`📊 SQLite Datenbank aktiv`);
|
console.log(`📊 SQLite Datenbank aktiv`);
|
||||||
|
|
||||||
|
// Speichere SchedulerService in app für Admin-Endpoints
|
||||||
|
this._app.set('schedulerService', SchedulerService);
|
||||||
|
|
||||||
// Starte Scheduler für automatisches Cleanup
|
// Starte Scheduler für automatisches Cleanup
|
||||||
SchedulerService.start();
|
SchedulerService.start();
|
||||||
|
|
||||||
|
// Teste Telegram-Service (optional, nur in Development wenn aktiviert)
|
||||||
|
if (process.env.NODE_ENV === 'development'
|
||||||
|
&& process.env.TELEGRAM_SEND_TEST_ON_START === 'true'
|
||||||
|
&& telegramService.isAvailable()) {
|
||||||
|
telegramService.sendTestMessage()
|
||||||
|
.catch(err => console.error('[Telegram] Test message failed:', err.message));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 Fehler beim Serverstart:', error);
|
console.error('💥 Fehler beim Serverstart:', error);
|
||||||
|
|
@ -95,8 +114,11 @@ class Server {
|
||||||
this._app.use('/upload', express.static( __dirname + '/upload'));
|
this._app.use('/upload', express.static( __dirname + '/upload'));
|
||||||
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
|
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) {
|
if (process.env.NODE_ENV !== 'production' && swaggerUi) {
|
||||||
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
const swaggerDocument = this.loadSwaggerDocument();
|
||||||
|
if (swaggerDocument) {
|
||||||
|
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return this._app;
|
return this._app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const GroupCleanupService = require('./GroupCleanupService');
|
const GroupCleanupService = require('./GroupCleanupService');
|
||||||
|
const TelegramNotificationService = require('./TelegramNotificationService');
|
||||||
|
|
||||||
class SchedulerService {
|
class SchedulerService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
|
this.telegramService = new TelegramNotificationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
|
@ -30,7 +32,35 @@ class SchedulerService {
|
||||||
|
|
||||||
this.tasks.push(cleanupTask);
|
this.tasks.push(cleanupTask);
|
||||||
|
|
||||||
console.log('✓ Scheduler started - Daily cleanup at 10:00 AM (Europe/Berlin)');
|
// Telegram Warning-Job: Jeden Tag um 09:00 Uhr (1 Stunde vor Cleanup)
|
||||||
|
const telegramWarningTask = cron.schedule('0 9 * * *', async () => {
|
||||||
|
console.log('[Scheduler] Running daily Telegram deletion warning at 09:00 AM...');
|
||||||
|
try {
|
||||||
|
if (this.telegramService.isAvailable()) {
|
||||||
|
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
|
||||||
|
|
||||||
|
if (groupsForDeletion && groupsForDeletion.length > 0) {
|
||||||
|
await this.telegramService.sendDeletionWarning(groupsForDeletion);
|
||||||
|
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
|
||||||
|
} else {
|
||||||
|
console.log('[Scheduler] No groups pending deletion');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Scheduler] Telegram service not available, skipping warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Scheduler] Telegram warning task failed:', error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
scheduled: true,
|
||||||
|
timezone: "Europe/Berlin"
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tasks.push(telegramWarningTask);
|
||||||
|
|
||||||
|
console.log('✓ Scheduler started:');
|
||||||
|
console.log(' - Daily cleanup at 10:00 AM (Europe/Berlin)');
|
||||||
|
console.log(' - Daily Telegram warning at 09:00 AM (Europe/Berlin)');
|
||||||
|
|
||||||
// Für Development: Manueller Trigger
|
// Für Development: Manueller Trigger
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|
@ -50,6 +80,42 @@ class SchedulerService {
|
||||||
console.log('[Scheduler] Manual cleanup triggered...');
|
console.log('[Scheduler] Manual cleanup triggered...');
|
||||||
return await GroupCleanupService.performScheduledCleanup();
|
return await GroupCleanupService.performScheduledCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Für Development: Manueller Telegram-Warning-Trigger
|
||||||
|
async triggerTelegramWarningNow() {
|
||||||
|
console.log('[Scheduler] Manual Telegram warning triggered...');
|
||||||
|
try {
|
||||||
|
if (!this.telegramService.isAvailable()) {
|
||||||
|
console.log('[Scheduler] Telegram service not available');
|
||||||
|
return { success: false, message: 'Telegram service not available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsForDeletion = await GroupCleanupService.findGroupsForDeletion();
|
||||||
|
|
||||||
|
if (!groupsForDeletion || groupsForDeletion.length === 0) {
|
||||||
|
console.log('[Scheduler] No groups pending deletion');
|
||||||
|
return { success: true, message: 'No groups pending deletion', groupCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.telegramService.sendDeletionWarning(groupsForDeletion);
|
||||||
|
console.log(`[Scheduler] Sent deletion warning for ${groupsForDeletion.length} groups`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Warning sent for ${groupsForDeletion.length} groups`,
|
||||||
|
groupCount: groupsForDeletion.length,
|
||||||
|
groups: groupsForDeletion.map(g => ({
|
||||||
|
groupId: g.groupId,
|
||||||
|
name: g.name,
|
||||||
|
year: g.year,
|
||||||
|
uploadDate: g.uploadDate
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Scheduler] Manual Telegram warning failed:', error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SchedulerService();
|
module.exports = new SchedulerService();
|
||||||
|
|
|
||||||
312
backend/src/services/TelegramNotificationService.js
Normal file
312
backend/src/services/TelegramNotificationService.js
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
const TelegramBot = require('node-telegram-bot-api');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TelegramNotificationService
|
||||||
|
*
|
||||||
|
* Versendet automatische Benachrichtigungen über Telegram an die Werkstatt-Gruppe.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Upload-Benachrichtigungen (Phase 3)
|
||||||
|
* - Consent-Änderungs-Benachrichtigungen (Phase 4)
|
||||||
|
* - Gruppen-Lösch-Benachrichtigungen (Phase 4)
|
||||||
|
* - Tägliche Lösch-Warnungen (Phase 5)
|
||||||
|
*
|
||||||
|
* Phase 2: Backend-Service Integration (Basic Setup)
|
||||||
|
*/
|
||||||
|
class TelegramNotificationService {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = process.env.TELEGRAM_ENABLED === 'true';
|
||||||
|
this.botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
this.chatId = process.env.TELEGRAM_CHAT_ID;
|
||||||
|
this.bot = null;
|
||||||
|
|
||||||
|
if (this.enabled) {
|
||||||
|
this.initialize();
|
||||||
|
} else {
|
||||||
|
console.log('[Telegram] Service disabled (TELEGRAM_ENABLED=false)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisiert den Telegram Bot
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
try {
|
||||||
|
if (!this.botToken) {
|
||||||
|
throw new Error('TELEGRAM_BOT_TOKEN is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.chatId) {
|
||||||
|
throw new Error('TELEGRAM_CHAT_ID is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bot = new TelegramBot(this.botToken, { polling: false });
|
||||||
|
console.log('[Telegram] Service initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Initialization failed:', error.message);
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der Service verfügbar ist
|
||||||
|
*/
|
||||||
|
isAvailable() {
|
||||||
|
return this.enabled && this.bot !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet eine Test-Nachricht
|
||||||
|
*
|
||||||
|
* @returns {Promise<Object>} Telegram API Response
|
||||||
|
*/
|
||||||
|
async sendTestMessage() {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.log('[Telegram] Service not available, skipping test message');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = `
|
||||||
|
🤖 Telegram Service Test
|
||||||
|
|
||||||
|
Service erfolgreich initialisiert!
|
||||||
|
|
||||||
|
Zeitstempel: ${timestamp}
|
||||||
|
Environment: ${process.env.NODE_ENV || 'development'}
|
||||||
|
|
||||||
|
---
|
||||||
|
ℹ️ Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const response = await this.bot.sendMessage(this.chatId, message);
|
||||||
|
console.log('[Telegram] Test message sent successfully');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Failed to send test message:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: Sendet Benachrichtigung bei neuem Upload
|
||||||
|
*
|
||||||
|
* @param {Object} groupData - Gruppen-Informationen
|
||||||
|
* @param {string} groupData.name - Name des Uploaders
|
||||||
|
* @param {number} groupData.year - Jahr der Gruppe
|
||||||
|
* @param {string} groupData.title - Titel der Gruppe
|
||||||
|
* @param {number} groupData.imageCount - Anzahl hochgeladener Bilder
|
||||||
|
* @param {boolean} groupData.workshopConsent - Workshop-Consent Status
|
||||||
|
* @param {Array<string>} groupData.socialMediaConsents - Social Media Plattformen
|
||||||
|
* @param {string} groupData.token - Management-Token
|
||||||
|
*/
|
||||||
|
async sendUploadNotification(groupData) {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.log('[Telegram] Service not available, skipping upload notification');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workshopIcon = groupData.workshopConsent ? '✅' : '❌';
|
||||||
|
const socialMediaIcons = this.formatSocialMediaIcons(groupData.socialMediaConsents);
|
||||||
|
|
||||||
|
const message = `
|
||||||
|
📸 Neuer Upload!
|
||||||
|
|
||||||
|
Uploader: ${groupData.name}
|
||||||
|
Bilder: ${groupData.imageCount}
|
||||||
|
Gruppe: ${groupData.year} - ${groupData.title}
|
||||||
|
|
||||||
|
Workshop: ${workshopIcon} ${groupData.workshopConsent ? 'Ja' : 'Nein'}
|
||||||
|
Social Media: ${socialMediaIcons || '❌ Keine'}
|
||||||
|
|
||||||
|
🔗 Zur Freigabe: ${this.getAdminUrl()}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const response = await this.bot.sendMessage(this.chatId, message);
|
||||||
|
console.log(`[Telegram] Upload notification sent for group: ${groupData.title}`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Failed to send upload notification:', error.message);
|
||||||
|
// Fehler loggen, aber nicht werfen - Upload soll nicht fehlschlagen wegen Telegram
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: Sendet Benachrichtigung bei Consent-Änderung
|
||||||
|
*
|
||||||
|
* @param {Object} changeData - Änderungs-Informationen
|
||||||
|
* @param {string} changeData.name - Name des Uploaders
|
||||||
|
* @param {number} changeData.year - Jahr
|
||||||
|
* @param {string} changeData.title - Titel
|
||||||
|
* @param {string} changeData.consentType - 'workshop' oder 'social_media'
|
||||||
|
* @param {string} changeData.action - 'revoke' oder 'restore'
|
||||||
|
* @param {string} [changeData.platform] - Plattform-Name (nur bei social_media)
|
||||||
|
* @param {boolean} [changeData.newValue] - Neuer Wert (nur bei workshop)
|
||||||
|
*/
|
||||||
|
async sendConsentChangeNotification(changeData) {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.log('[Telegram] Service not available, skipping consent change notification');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, year, title, consentType, action, platform, newValue } = changeData;
|
||||||
|
|
||||||
|
let changeDescription;
|
||||||
|
if (consentType === 'workshop') {
|
||||||
|
const icon = newValue ? '✅' : '❌';
|
||||||
|
const status = newValue ? 'Ja' : 'Nein';
|
||||||
|
const actionText = action === 'revoke' ? 'widerrufen' : 'wiederhergestellt';
|
||||||
|
changeDescription = `Workshop-Consent ${actionText}\nNeuer Status: ${icon} ${status}`;
|
||||||
|
} else if (consentType === 'social_media') {
|
||||||
|
const actionText = action === 'revoke' ? 'widerrufen' : 'erteilt';
|
||||||
|
changeDescription = `Social Media Consent ${actionText}\nPlattform: ${platform}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `
|
||||||
|
⚙️ Consent-Änderung
|
||||||
|
|
||||||
|
Gruppe: ${year} - ${title}
|
||||||
|
Uploader: ${name}
|
||||||
|
|
||||||
|
${changeDescription}
|
||||||
|
|
||||||
|
🔗 Details: ${this.getAdminUrl()}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const response = await this.bot.sendMessage(this.chatId, message);
|
||||||
|
console.log(`[Telegram] Consent change notification sent for: ${title}`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Failed to send consent change notification:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: Sendet Benachrichtigung bei Gruppen-Löschung durch User
|
||||||
|
*
|
||||||
|
* @param {Object} groupData - Gruppen-Informationen
|
||||||
|
*/
|
||||||
|
async sendGroupDeletedNotification(groupData) {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.log('[Telegram] Service not available, skipping group deleted notification');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = `
|
||||||
|
⚙️ User-Änderung
|
||||||
|
|
||||||
|
Aktion: Gruppe gelöscht
|
||||||
|
Gruppe: ${groupData.year} - ${groupData.title}
|
||||||
|
Uploader: ${groupData.name}
|
||||||
|
Bilder: ${groupData.imageCount}
|
||||||
|
|
||||||
|
ℹ️ User hat Gruppe selbst über Management-Link gelöscht
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const response = await this.bot.sendMessage(this.chatId, message);
|
||||||
|
console.log(`[Telegram] Group deleted notification sent for: ${groupData.title}`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Failed to send group deleted notification:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5: Sendet tägliche Warnung für bevorstehende Löschungen
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} groupsList - Liste der zu löschenden Gruppen
|
||||||
|
*/
|
||||||
|
async sendDeletionWarning(groupsList) {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
console.log('[Telegram] Service not available, skipping deletion warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupsList || groupsList.length === 0) {
|
||||||
|
console.log('[Telegram] No groups pending deletion, skipping warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let groupsText = groupsList.map((group, index) => {
|
||||||
|
const uploadDate = new Date(group.created_at).toLocaleDateString('de-DE');
|
||||||
|
return `${index + 1}. ${group.year} - ${group.title}
|
||||||
|
Uploader: ${group.name}
|
||||||
|
Bilder: ${group.imageCount}
|
||||||
|
Hochgeladen: ${uploadDate}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
const message = `
|
||||||
|
⏰ Löschung in 24 Stunden!
|
||||||
|
|
||||||
|
Folgende Gruppen werden morgen automatisch gelöscht:
|
||||||
|
|
||||||
|
${groupsText}
|
||||||
|
|
||||||
|
💡 Jetzt freigeben oder Freigabe bleibt aus!
|
||||||
|
🔗 Zur Moderation: ${this.getAdminUrl()}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const response = await this.bot.sendMessage(this.chatId, message);
|
||||||
|
console.log(`[Telegram] Deletion warning sent for ${groupsList.length} groups`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Telegram] Failed to send deletion warning:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert Social Media Consents als Icons
|
||||||
|
*
|
||||||
|
* @param {Array<string>} platforms - Liste der Plattformen
|
||||||
|
* @returns {string} Formatierter String mit Icons
|
||||||
|
*/
|
||||||
|
formatSocialMediaIcons(platforms) {
|
||||||
|
if (!platforms || platforms.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
'facebook': '📘 Facebook',
|
||||||
|
'instagram': '📷 Instagram',
|
||||||
|
'tiktok': '🎵 TikTok'
|
||||||
|
};
|
||||||
|
|
||||||
|
return platforms.map(p => iconMap[p.toLowerCase()] || p).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Admin-URL zurück
|
||||||
|
*
|
||||||
|
* @returns {string} Admin-Panel URL
|
||||||
|
*/
|
||||||
|
getAdminUrl() {
|
||||||
|
const host = process.env.INTERNAL_HOST || 'internal.hobbyhimmel.de';
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const protocol = isProduction ? 'https' : 'http';
|
||||||
|
const port = isProduction ? '' : ':3000';
|
||||||
|
return `${protocol}://${host}${port}/moderation`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TelegramNotificationService;
|
||||||
183
backend/tests/api/telegram-upload.test.js
Normal file
183
backend/tests/api/telegram-upload.test.js
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Integration Tests für Telegram Upload-Benachrichtigungen
|
||||||
|
*
|
||||||
|
* Phase 3: Upload-Benachrichtigungen
|
||||||
|
*
|
||||||
|
* Diese Tests prüfen die Integration zwischen Upload-Route und Telegram-Service
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { getRequest } = require('../testServer');
|
||||||
|
|
||||||
|
describe('Telegram Upload Notifications (Integration)', () => {
|
||||||
|
let TelegramNotificationService;
|
||||||
|
let sendUploadNotificationSpy;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Spy auf TelegramNotificationService
|
||||||
|
TelegramNotificationService = require('../../src/services/TelegramNotificationService');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Spy auf sendUploadNotification erstellen
|
||||||
|
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification')
|
||||||
|
.mockResolvedValue({ message_id: 42 });
|
||||||
|
|
||||||
|
// isAvailable() immer true zurückgeben für Tests
|
||||||
|
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
|
||||||
|
.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore alle Spys
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/upload/batch', () => {
|
||||||
|
const testImagePath = path.join(__dirname, '../utils/test-image.jpg');
|
||||||
|
|
||||||
|
// Erstelle Test-Bild falls nicht vorhanden
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!fs.existsSync(testImagePath)) {
|
||||||
|
// Erstelle 1x1 px JPEG
|
||||||
|
const buffer = Buffer.from([
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
|
||||||
|
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
|
||||||
|
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
||||||
|
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
|
||||||
|
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
|
||||||
|
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
|
||||||
|
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
|
||||||
|
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
|
||||||
|
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
|
||||||
|
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
|
||||||
|
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
|
||||||
|
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
|
||||||
|
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
|
||||||
|
0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x03, 0xFF, 0xC4, 0x00, 0x14,
|
||||||
|
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
|
||||||
|
0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9
|
||||||
|
]);
|
||||||
|
fs.writeFileSync(testImagePath, buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte Telegram-Benachrichtigung bei erfolgreichem Upload senden', async () => {
|
||||||
|
const response = await getRequest()
|
||||||
|
.post('/api/upload/batch')
|
||||||
|
.field('year', '2024')
|
||||||
|
.field('title', 'Test Upload')
|
||||||
|
.field('name', 'Test User')
|
||||||
|
.field('consents', JSON.stringify({
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: ['instagram', 'tiktok']
|
||||||
|
}))
|
||||||
|
.attach('images', testImagePath);
|
||||||
|
|
||||||
|
// Upload sollte erfolgreich sein
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Batch upload successful');
|
||||||
|
|
||||||
|
// Warte kurz auf async Telegram-Call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Telegram-Service sollte aufgerufen worden sein
|
||||||
|
expect(sendUploadNotificationSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Test User',
|
||||||
|
year: 2024,
|
||||||
|
title: 'Test Upload',
|
||||||
|
imageCount: 1,
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: ['instagram', 'tiktok']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte Upload nicht fehlschlagen wenn Telegram-Service nicht verfügbar', async () => {
|
||||||
|
// Restore mock und setze isAvailable auf false
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.spyOn(TelegramNotificationService.prototype, 'isAvailable')
|
||||||
|
.mockReturnValue(false);
|
||||||
|
sendUploadNotificationSpy = jest.spyOn(TelegramNotificationService.prototype, 'sendUploadNotification');
|
||||||
|
|
||||||
|
const response = await getRequest()
|
||||||
|
.post('/api/upload/batch')
|
||||||
|
.field('year', '2024')
|
||||||
|
.field('title', 'Test Upload')
|
||||||
|
.field('name', 'Test User')
|
||||||
|
.field('consents', JSON.stringify({
|
||||||
|
workshopConsent: false,
|
||||||
|
socialMediaConsents: []
|
||||||
|
}))
|
||||||
|
.attach('images', testImagePath);
|
||||||
|
|
||||||
|
// Upload sollte trotzdem erfolgreich sein
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Batch upload successful');
|
||||||
|
|
||||||
|
// Telegram sollte nicht aufgerufen worden sein
|
||||||
|
expect(sendUploadNotificationSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte Upload nicht fehlschlagen wenn Telegram-Benachrichtigung fehlschlägt', async () => {
|
||||||
|
sendUploadNotificationSpy.mockRejectedValueOnce(
|
||||||
|
new Error('Telegram API Error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await getRequest()
|
||||||
|
.post('/api/upload/batch')
|
||||||
|
.field('year', '2024')
|
||||||
|
.field('title', 'Test Upload')
|
||||||
|
.field('name', 'Test User')
|
||||||
|
.field('consents', JSON.stringify({
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: []
|
||||||
|
}))
|
||||||
|
.attach('images', testImagePath);
|
||||||
|
|
||||||
|
// Upload sollte trotzdem erfolgreich sein
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.message).toBe('Batch upload successful');
|
||||||
|
|
||||||
|
// Warte auf async error handling
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Telegram wurde versucht aufzurufen
|
||||||
|
expect(sendUploadNotificationSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte korrekte Daten an Telegram-Service übergeben', async () => {
|
||||||
|
const response = await getRequest()
|
||||||
|
.post('/api/upload/batch')
|
||||||
|
.field('year', '2025')
|
||||||
|
.field('title', 'Schweißkurs November')
|
||||||
|
.field('name', 'Max Mustermann')
|
||||||
|
.field('consents', JSON.stringify({
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: ['facebook', 'instagram']
|
||||||
|
}))
|
||||||
|
.attach('images', testImagePath)
|
||||||
|
.attach('images', testImagePath);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
expect(sendUploadNotificationSpy).toHaveBeenCalledWith({
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
year: 2025,
|
||||||
|
title: 'Schweißkurs November',
|
||||||
|
imageCount: 2,
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: ['facebook', 'instagram'],
|
||||||
|
token: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
backend/tests/unit/TelegramNotificationService.test.js
Normal file
216
backend/tests/unit/TelegramNotificationService.test.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
/**
|
||||||
|
* Unit Tests für TelegramNotificationService
|
||||||
|
*
|
||||||
|
* Phase 2: Basic Service Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TelegramNotificationService = require('../../src/services/TelegramNotificationService');
|
||||||
|
|
||||||
|
// Mock node-telegram-bot-api komplett
|
||||||
|
jest.mock('node-telegram-bot-api');
|
||||||
|
|
||||||
|
describe('TelegramNotificationService', () => {
|
||||||
|
let originalEnv;
|
||||||
|
let TelegramBot;
|
||||||
|
let mockBotInstance;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
TelegramBot = require('node-telegram-bot-api');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Speichere originale ENV-Variablen
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Setze Test-ENV
|
||||||
|
process.env.TELEGRAM_ENABLED = 'true';
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = 'test-bot-token-123';
|
||||||
|
process.env.TELEGRAM_CHAT_ID = '-1001234567890';
|
||||||
|
|
||||||
|
// Erstelle Mock-Bot-Instanz
|
||||||
|
mockBotInstance = {
|
||||||
|
sendMessage: jest.fn().mockResolvedValue({
|
||||||
|
message_id: 42,
|
||||||
|
chat: { id: -1001234567890 },
|
||||||
|
text: 'Test'
|
||||||
|
}),
|
||||||
|
getMe: jest.fn().mockResolvedValue({
|
||||||
|
id: 123456,
|
||||||
|
first_name: 'Test Bot',
|
||||||
|
username: 'test_bot'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock TelegramBot constructor
|
||||||
|
TelegramBot.mockImplementation(() => mockBotInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original ENV
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('sollte erfolgreich initialisieren wenn TELEGRAM_ENABLED=true', () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
expect(service.isAvailable()).toBe(true);
|
||||||
|
expect(TelegramBot).toHaveBeenCalledWith('test-bot-token-123', { polling: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte nicht initialisieren wenn TELEGRAM_ENABLED=false', () => {
|
||||||
|
process.env.TELEGRAM_ENABLED = 'false';
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
expect(service.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte fehlschlagen wenn TELEGRAM_BOT_TOKEN fehlt', () => {
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
expect(service.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte fehlschlagen wenn TELEGRAM_CHAT_ID fehlt', () => {
|
||||||
|
delete process.env.TELEGRAM_CHAT_ID;
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
expect(service.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendTestMessage', () => {
|
||||||
|
it('sollte Test-Nachricht erfolgreich senden', async () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const result = await service.sendTestMessage();
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.message_id).toBe(42);
|
||||||
|
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'-1001234567890',
|
||||||
|
expect.stringContaining('Telegram Service Test')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte null zurückgeben wenn Service nicht verfügbar', async () => {
|
||||||
|
process.env.TELEGRAM_ENABLED = 'false';
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const result = await service.sendTestMessage();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte Fehler werfen bei Telegram-API-Fehler', async () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('API Error'));
|
||||||
|
|
||||||
|
await expect(service.sendTestMessage()).rejects.toThrow('API Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSocialMediaIcons', () => {
|
||||||
|
it('sollte Social Media Plattformen korrekt formatieren', () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const result = service.formatSocialMediaIcons(['facebook', 'instagram', 'tiktok']);
|
||||||
|
|
||||||
|
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte leeren String bei leerer Liste zurückgeben', () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const result = service.formatSocialMediaIcons([]);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte case-insensitive sein', () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const result = service.formatSocialMediaIcons(['FACEBOOK', 'Instagram', 'TikTok']);
|
||||||
|
|
||||||
|
expect(result).toBe('📘 Facebook, 📷 Instagram, 🎵 TikTok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAdminUrl', () => {
|
||||||
|
it('sollte Admin-URL mit PUBLIC_URL generieren', () => {
|
||||||
|
process.env.PUBLIC_URL = 'https://test.example.com';
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const url = service.getAdminUrl();
|
||||||
|
|
||||||
|
expect(url).toBe('https://test.example.com/moderation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte Default-URL verwenden wenn PUBLIC_URL nicht gesetzt', () => {
|
||||||
|
delete process.env.PUBLIC_URL;
|
||||||
|
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const url = service.getAdminUrl();
|
||||||
|
|
||||||
|
expect(url).toBe('https://internal.hobbyhimmel.de/moderation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendUploadNotification (Phase 3)', () => {
|
||||||
|
it('sollte Upload-Benachrichtigung mit korrekten Daten senden', async () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
const groupData = {
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
year: 2024,
|
||||||
|
title: 'Schweißkurs November',
|
||||||
|
imageCount: 12,
|
||||||
|
workshopConsent: true,
|
||||||
|
socialMediaConsents: ['instagram', 'tiktok'],
|
||||||
|
token: 'test-token-123'
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.sendUploadNotification(groupData);
|
||||||
|
|
||||||
|
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'-1001234567890',
|
||||||
|
expect.stringContaining('📸 Neuer Upload!')
|
||||||
|
);
|
||||||
|
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'-1001234567890',
|
||||||
|
expect.stringContaining('Max Mustermann')
|
||||||
|
);
|
||||||
|
expect(mockBotInstance.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'-1001234567890',
|
||||||
|
expect.stringContaining('Bilder: 12')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sollte null zurückgeben und nicht werfen bei Fehler', async () => {
|
||||||
|
const service = new TelegramNotificationService();
|
||||||
|
|
||||||
|
mockBotInstance.sendMessage.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const groupData = {
|
||||||
|
name: 'Test User',
|
||||||
|
year: 2024,
|
||||||
|
title: 'Test',
|
||||||
|
imageCount: 5,
|
||||||
|
workshopConsent: false,
|
||||||
|
socialMediaConsents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.sendUploadNotification(groupData);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
267
backend/tests/unit/middlewares/hostGate.test.js
Normal file
267
backend/tests/unit/middlewares/hostGate.test.js
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
/**
|
||||||
|
* Unit Tests für hostGate Middleware
|
||||||
|
* Testet Host-basierte Zugriffskontrolle
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Setup ENV VOR dem Require
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
|
process.env.PUBLIC_HOST = 'public.example.com';
|
||||||
|
process.env.INTERNAL_HOST = 'internal.example.com';
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
|
||||||
|
let hostGate;
|
||||||
|
|
||||||
|
// Helper to create mock request with headers
|
||||||
|
const createMockRequest = (hostname, path = '/') => {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
get: (headerName) => {
|
||||||
|
if (headerName.toLowerCase() === 'x-forwarded-host') {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
if (headerName.toLowerCase() === 'host') {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Host Gate Middleware', () => {
|
||||||
|
let req, res, next;
|
||||||
|
let originalEnv;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Sichere Original-Env
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Lade Modul NACH ENV setup
|
||||||
|
hostGate = require('../../../src/middlewares/hostGate');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock response object
|
||||||
|
res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock next function
|
||||||
|
next = jest.fn();
|
||||||
|
|
||||||
|
// Reset req for each test
|
||||||
|
req = null;
|
||||||
|
|
||||||
|
// Setup Environment
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
|
process.env.PUBLIC_HOST = 'public.example.com';
|
||||||
|
process.env.INTERNAL_HOST = 'internal.example.com';
|
||||||
|
process.env.NODE_ENV = 'development'; // NOT 'test' to enable restrictions
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore Original-Env
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Host Detection', () => {
|
||||||
|
test('should detect public host from X-Forwarded-Host header', () => {
|
||||||
|
req = createMockRequest('public.example.com');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.isPublicHost).toBe(true);
|
||||||
|
expect(req.isInternalHost).toBe(false);
|
||||||
|
expect(req.requestSource).toBe('public');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should detect internal host from X-Forwarded-Host header', () => {
|
||||||
|
req = createMockRequest('internal.example.com');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.isPublicHost).toBe(false);
|
||||||
|
expect(req.isInternalHost).toBe(true);
|
||||||
|
expect(req.requestSource).toBe('internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to Host header if X-Forwarded-Host not present', () => {
|
||||||
|
req = createMockRequest('public.example.com');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.isPublicHost).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle localhost as internal host', () => {
|
||||||
|
req = createMockRequest('localhost:3000');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.isInternalHost).toBe(true);
|
||||||
|
expect(req.isPublicHost).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should strip port from hostname', () => {
|
||||||
|
req = createMockRequest('public.example.com:8080');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.isPublicHost).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Route Protection', () => {
|
||||||
|
test('should block admin routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/admin/deletion-log');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Not available on public host',
|
||||||
|
message: 'This endpoint is only available on the internal network'
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block groups routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/groups');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block slideshow routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/slideshow');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block migration routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/migration/start');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block auth login on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/auth/login');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Allowed Routes', () => {
|
||||||
|
test('should allow upload route on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/upload');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow manage routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/manage/abc-123');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow preview routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/previews/image.jpg');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow consent routes on public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/consent');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow all routes on internal host', () => {
|
||||||
|
req = createMockRequest('internal.example.com', '/api/admin/deletion-log');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature Flags', () => {
|
||||||
|
test('should bypass restriction when NODE_ENV is test and not explicitly enabled', () => {
|
||||||
|
// Reload module with test environment
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'false'; // Explicitly disabled
|
||||||
|
const hostGateTest = require('../../../src/middlewares/hostGate');
|
||||||
|
|
||||||
|
req = createMockRequest('public.example.com', '/api/admin/test');
|
||||||
|
hostGateTest(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
expect(req.isInternalHost).toBe(true);
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should work in test environment when explicitly enabled', () => {
|
||||||
|
// Reload module with test environment BUT explicitly enabled
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true'; // Explicitly enabled
|
||||||
|
const hostGateTest = require('../../../src/middlewares/hostGate');
|
||||||
|
|
||||||
|
req = createMockRequest('public.example.com', '/api/admin/test');
|
||||||
|
hostGateTest(req, res, next);
|
||||||
|
|
||||||
|
// Should block because explicitly enabled
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Request Source Tracking', () => {
|
||||||
|
test('should set requestSource to "public" for public host', () => {
|
||||||
|
req = createMockRequest('public.example.com', '/api/upload');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.requestSource).toBe('public');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set requestSource to "internal" for internal host', () => {
|
||||||
|
req = createMockRequest('internal.example.com', '/api/admin/test');
|
||||||
|
hostGate(req, res, next);
|
||||||
|
|
||||||
|
expect(req.requestSource).toBe('internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set requestSource to "internal" when restrictions disabled', () => {
|
||||||
|
// Reload module with disabled restriction
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'false';
|
||||||
|
const hostGateDisabled = require('../../../src/middlewares/hostGate');
|
||||||
|
|
||||||
|
req = createMockRequest('anything.example.com', '/api/test');
|
||||||
|
hostGateDisabled(req, res, next);
|
||||||
|
|
||||||
|
expect(req.requestSource).toBe('internal');
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
backend/tests/utils/test-image.jpg
Normal file
BIN
backend/tests/utils/test-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
|
|
@ -10,6 +10,22 @@ NODE_ENV=development
|
||||||
# Port for the backend server
|
# Port for the backend server
|
||||||
PORT=5000
|
PORT=5000
|
||||||
|
|
||||||
|
# Admin Session Secret (IMPORTANT: Change in production!)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
ADMIN_SESSION_SECRET=change-me-in-production
|
||||||
|
|
||||||
|
# Telegram Bot Configuration (optional)
|
||||||
|
TELEGRAM_ENABLED=false
|
||||||
|
# Send test message on server start (development only)
|
||||||
|
TELEGRAM_SEND_TEST_ON_START=false
|
||||||
|
# Bot-Token from @BotFather
|
||||||
|
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
# Chat-ID of the Telegram group (negative for groups!)
|
||||||
|
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
# Example: -1001234567890
|
||||||
|
TELEGRAM_CHAT_ID=your-chat-id-here
|
||||||
|
|
||||||
# Database settings (if needed in future)
|
# Database settings (if needed in future)
|
||||||
# DB_HOST=localhost
|
# DB_HOST=localhost
|
||||||
# DB_PORT=3306
|
# DB_PORT=3306
|
||||||
|
|
@ -6,7 +6,4 @@
|
||||||
# Production: http://backend:5000 (container-to-container)
|
# Production: http://backend:5000 (container-to-container)
|
||||||
API_URL=http://backend:5000
|
API_URL=http://backend:5000
|
||||||
|
|
||||||
# Client URL - the URL where users access the frontend
|
# Public/Internal host separation (optional)
|
||||||
# Development: http://localhost:3000 (dev server)
|
|
||||||
# Production: http://localhost (nginx on port 80)
|
|
||||||
CLIENT_URL=http://localhost
|
|
||||||
16
docker/dev/.env.example
Normal file
16
docker/dev/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Docker Compose Environment Variables for Development
|
||||||
|
# Copy this file to .env and adjust values
|
||||||
|
|
||||||
|
# Admin Session Secret (optional, has default: dev-session-secret-change-me)
|
||||||
|
#ADMIN_SESSION_SECRET=your-secret-here
|
||||||
|
|
||||||
|
# Telegram Bot Configuration (optional)
|
||||||
|
TELEGRAM_ENABLED=false
|
||||||
|
TELEGRAM_SEND_TEST_ON_START=false
|
||||||
|
# Bot-Token from @BotFather
|
||||||
|
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
# Chat-ID of the Telegram group (negative for groups!)
|
||||||
|
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
# Example: -1001234567890
|
||||||
|
TELEGRAM_CHAT_ID=your-chat-id-here
|
||||||
|
|
@ -12,8 +12,8 @@ RUN npm install
|
||||||
# Copy backend source code
|
# Copy backend source code
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
# Copy development environment configuration
|
# Note: Environment variables are set via docker-compose.yml
|
||||||
COPY docker/dev/backend/config/.env ./.env
|
# No .env file needed in the image
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ../../frontend:/app:cached
|
- ../../frontend:/app:cached
|
||||||
- dev_frontend_node_modules:/app/node_modules
|
- dev_frontend_node_modules:/app/node_modules
|
||||||
- ./frontend/config/.env:/app/.env:ro
|
|
||||||
environment:
|
environment:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
- API_URL=http://localhost:5001
|
- API_URL=http://localhost:5001
|
||||||
- CLIENT_URL=http://localhost:3000
|
- PUBLIC_HOST=public.test.local
|
||||||
|
- INTERNAL_HOST=internal.test.local
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-dev
|
- backend-dev
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -37,9 +37,20 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend:/usr/src/app:cached
|
- ../../backend:/usr/src/app:cached
|
||||||
- dev_backend_node_modules:/usr/src/app/node_modules
|
- dev_backend_node_modules:/usr/src/app/node_modules
|
||||||
- ./backend/config/.env:/usr/src/app/.env:ro
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
|
- PORT=5000
|
||||||
|
- REMOVE_IMAGES=false
|
||||||
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-dev-session-secret-change-me}
|
||||||
|
- PUBLIC_HOST=public.test.local
|
||||||
|
- INTERNAL_HOST=internal.test.local
|
||||||
|
- ENABLE_HOST_RESTRICTION=true
|
||||||
|
- TRUST_PROXY_HOPS=0
|
||||||
|
- PUBLIC_UPLOAD_RATE_LIMIT=20
|
||||||
|
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||||
|
- TELEGRAM_SEND_TEST_ON_START=${TELEGRAM_SEND_TEST_ON_START:-false}
|
||||||
networks:
|
networks:
|
||||||
- dev-internal
|
- dev-internal
|
||||||
command: [ "npm", "run", "server" ]
|
command: [ "npm", "run", "server" ]
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ WORKDIR /app
|
||||||
# Copy package files first to leverage Docker cache for npm install
|
# Copy package files first to leverage Docker cache for npm install
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
# Copy environment configuration
|
# Copy environment shell script (generates env-config.js from ENV at runtime)
|
||||||
COPY docker/dev/frontend/config/env.sh ./env.sh
|
COPY docker/dev/frontend/config/env.sh ./env.sh
|
||||||
COPY docker/dev/frontend/config/.env ./.env
|
# Note: ENV variables are set via docker-compose.yml, not from .env file
|
||||||
|
|
||||||
# Make env.sh executable
|
# Make env.sh executable
|
||||||
RUN chmod +x ./env.sh
|
RUN chmod +x ./env.sh
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,18 @@ touch ./env-config.js
|
||||||
# Add assignment
|
# Add assignment
|
||||||
echo "window._env_ = {" >> ./env-config.js
|
echo "window._env_ = {" >> ./env-config.js
|
||||||
|
|
||||||
# Read each line in .env file
|
# List of environment variables to export (add more as needed)
|
||||||
# Each line represents key=value pairs
|
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
|
||||||
while read -r line || [[ -n "$line" ]];
|
|
||||||
do
|
|
||||||
# Split env variables by character `=`
|
|
||||||
if printf '%s\n' "$line" | grep -q -e '='; then
|
|
||||||
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
|
||||||
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read value of current variable if exists as Environment variable
|
# Read each environment variable and add to config
|
||||||
value=$(printf '%s\n' "${!varname}")
|
for varname in $ENV_VARS; do
|
||||||
# Otherwise use value from .env file
|
# Get value from environment using indirect expansion
|
||||||
[[ -z $value ]] && value=${varvalue}
|
value="${!varname}"
|
||||||
|
|
||||||
# Append configuration property to JS file
|
# Only add if value exists
|
||||||
echo " $varname: \"$value\"," >> ./env-config.js
|
if [ -n "$value" ]; then
|
||||||
done < .env
|
echo " $varname: \"$value\"," >> ./env-config.js
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "}" >> ./env-config.js
|
echo "}" >> ./env-config.js
|
||||||
|
|
|
||||||
18
docker/prod/.env.example
Normal file
18
docker/prod/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Docker Compose Environment Variables for Production
|
||||||
|
# Copy this file to .env and adjust values
|
||||||
|
# IMPORTANT: Keep this file secure and never commit .env to git!
|
||||||
|
|
||||||
|
# Admin Session Secret (REQUIRED: Generate new secret!)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
ADMIN_SESSION_SECRET=CHANGE-ME-IN-PRODUCTION
|
||||||
|
|
||||||
|
# Telegram Bot Configuration (optional)
|
||||||
|
# Set to true to enable Telegram notifications in production
|
||||||
|
TELEGRAM_ENABLED=false
|
||||||
|
# Bot-Token from @BotFather (production bot)
|
||||||
|
# Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
||||||
|
TELEGRAM_BOT_TOKEN=your-production-bot-token-here
|
||||||
|
# Chat-ID of the production Telegram group (negative for groups!)
|
||||||
|
# Get via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
# Example: -1001234567890
|
||||||
|
TELEGRAM_CHAT_ID=your-production-chat-id-here
|
||||||
|
|
@ -15,7 +15,7 @@ RUN npm install --production
|
||||||
COPY backend/src ./src
|
COPY backend/src ./src
|
||||||
|
|
||||||
# Copy production environment configuration
|
# Copy production environment configuration
|
||||||
#COPY docker/prod/backend/config/.env ./.env
|
# COPY docker/prod/backend/config/.env ./.env
|
||||||
|
|
||||||
# Create data directories for file storage
|
# Create data directories for file storage
|
||||||
RUN mkdir -p src/data/images src/data/previews src/data/groups
|
RUN mkdir -p src/data/images src/data/previews src/data/groups
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ services:
|
||||||
- backend
|
- backend
|
||||||
environment:
|
environment:
|
||||||
- API_URL=http://backend:5000
|
- API_URL=http://backend:5000
|
||||||
- CLIENT_URL=http://localhost
|
- PUBLIC_HOST=public.test.local
|
||||||
|
- INTERNAL_HOST=internal.test.local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- npm-nw
|
- npm-nw
|
||||||
|
|
@ -36,10 +37,23 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- REMOVE_IMAGES=false
|
- REMOVE_IMAGES=false
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- ADMIN_SESSION_SECRET=MvFhivVIPIXvSGvWGfGOiQCkUJrmUsjWQTNGUgnSmtpsGHQlKruTBEBZgbVvOHHr
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
|
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
|
||||||
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
|
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
|
||||||
- ADMIN_SESSION_COOKIE_SECURE=true
|
- ADMIN_SESSION_COOKIE_SECURE=true
|
||||||
|
# Host Configuration (Public/Internal Separation)
|
||||||
|
- PUBLIC_HOST=public.test.local
|
||||||
|
- INTERNAL_HOST=internal.test.local
|
||||||
|
- ENABLE_HOST_RESTRICTION=true
|
||||||
|
- PUBLIC_UPLOAD_RATE_LIMIT=20
|
||||||
|
- PUBLIC_UPLOAD_RATE_WINDOW=3600000
|
||||||
|
# Trust nginx-proxy-manager (1 hop)
|
||||||
|
- TRUST_PROXY_HOPS=1
|
||||||
|
# Telegram Bot Configuration (optional)
|
||||||
|
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||||
|
- TELEGRAM_SEND_TEST_ON_START=false
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ COPY --from=build /app/build /usr/share/nginx/html
|
||||||
# Default port exposure
|
# Default port exposure
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Copy .env file and shell script to container
|
# Copy .env shell script to container (generates env-config.js from ENV at runtime)
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
COPY docker/prod/frontend/config/env.sh ./env.sh
|
COPY docker/prod/frontend/config/env.sh ./env.sh
|
||||||
COPY docker/prod/frontend/config/.env ./.env
|
# Note: ENV variables are set via docker-compose.yml, not from .env file
|
||||||
|
|
||||||
# Add bash
|
# Add bash
|
||||||
RUN apk add --no-cache bash
|
RUN apk add --no-cache bash
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,18 @@ touch ./env-config.js
|
||||||
# Add assignment
|
# Add assignment
|
||||||
echo "window._env_ = {" >> ./env-config.js
|
echo "window._env_ = {" >> ./env-config.js
|
||||||
|
|
||||||
# Read each line in .env file
|
# List of environment variables to export (add more as needed)
|
||||||
# Each line represents key=value pairs
|
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
|
||||||
while read -r line || [[ -n "$line" ]];
|
|
||||||
do
|
|
||||||
# Split env variables by character `=`
|
|
||||||
if printf '%s\n' "$line" | grep -q -e '='; then
|
|
||||||
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
|
||||||
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read value of current variable if exists as Environment variable
|
# Read each environment variable and add to config
|
||||||
value=$(printf '%s\n' "${!varname}")
|
for varname in $ENV_VARS; do
|
||||||
# Otherwise use value from .env file
|
# Get value from environment using indirect expansion
|
||||||
[[ -z $value ]] && value=${varvalue}
|
value="${!varname}"
|
||||||
|
|
||||||
# Append configuration property to JS file
|
# Only add if value exists
|
||||||
echo " $varname: \"$value\"," >> ./env-config.js
|
if [ -n "$value" ]; then
|
||||||
done < .env
|
echo " $varname: \"$value\"," >> ./env-config.js
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "}" >> ./env-config.js
|
echo "}" >> ./env-config.js
|
||||||
|
|
|
||||||
6
frontend/.env.development
Normal file
6
frontend/.env.development
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Development Environment Variables
|
||||||
|
# Allow access from custom hostnames (public.test.local, internal.test.local)
|
||||||
|
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
||||||
|
|
||||||
|
# Use 0.0.0.0 to allow external access
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
@ -4,3 +4,7 @@
|
||||||
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
|
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
|
||||||
# Example:
|
# Example:
|
||||||
# REACT_APP_PUBLIC_API_BASE=https://example.com
|
# REACT_APP_PUBLIC_API_BASE=https://example.com
|
||||||
|
|
||||||
|
# Host Configuration (for public/internal separation)
|
||||||
|
PUBLIC_HOST=deinprojekt.hobbyhimmel.de
|
||||||
|
INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
|
||||||
|
|
|
||||||
149
frontend/ERROR_HANDLING.md
Normal file
149
frontend/ERROR_HANDLING.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Error Handling System
|
||||||
|
|
||||||
|
Das Frontend verfügt jetzt über ein vollständiges Error Handling System für HTTP-Fehler und React-Fehler.
|
||||||
|
|
||||||
|
## ✅ Migration abgeschlossen
|
||||||
|
|
||||||
|
Alle kritischen API-Aufrufe wurden auf das neue Error-Handling-System migriert:
|
||||||
|
- ✅ `sendRequest.js` → `apiClient` (axios-basiert)
|
||||||
|
- ✅ `batchUpload.js` → `apiFetch`
|
||||||
|
- ✅ `PublicGroupImagesPage.js` → `apiFetch`
|
||||||
|
- ✅ `ManagementPortalPage.js` → `apiFetch`
|
||||||
|
- ✅ `DeleteGroupButton.js` → `apiFetch`
|
||||||
|
- ✅ `ConsentManager.js` → `apiFetch`
|
||||||
|
- ✅ `ImageDescriptionManager.js` → `apiFetch`
|
||||||
|
- ✅ `GroupMetadataEditor.js` → `apiFetch`
|
||||||
|
|
||||||
|
**Hinweis:** `adminApi.js` und `socialMediaApi.js` verwenden ihr eigenes `adminFetch`-System mit CSRF-Token-Handling und wurden bewusst nicht migriert.
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
### 1. ErrorBoundary (`/Components/ComponentUtils/ErrorBoundary.js`)
|
||||||
|
- Fängt React-Fehler (z.B. Rendering-Fehler) ab
|
||||||
|
- Zeigt automatisch die 500-Error-Page bei unerwarteten Fehlern
|
||||||
|
- Loggt Fehlerdetails in der Konsole für Debugging
|
||||||
|
|
||||||
|
### 2. API Client (`/Utils/apiClient.js`)
|
||||||
|
- Axios-Instance mit Response-Interceptor
|
||||||
|
- Für FormData-Uploads (z.B. Bilder)
|
||||||
|
- Automatische Weiterleitung zu Error-Pages basierend auf HTTP-Statuscode
|
||||||
|
|
||||||
|
### 3. API Fetch Wrapper (`/Utils/apiFetch.js`)
|
||||||
|
- Native Fetch-Wrapper mit Error-Handling
|
||||||
|
- Für Standard-JSON-API-Aufrufe
|
||||||
|
- Automatische Weiterleitung zu Error-Pages:
|
||||||
|
- **403 Forbidden** → `/error/403`
|
||||||
|
- **500 Internal Server Error** → `/error/500`
|
||||||
|
- **502 Bad Gateway** → `/error/502`
|
||||||
|
- **503 Service Unavailable** → `/error/503`
|
||||||
|
|
||||||
|
### 4. Error Pages Routes (`App.js`)
|
||||||
|
- Neue Routes für alle Error-Pages:
|
||||||
|
- `/error/403` - Forbidden
|
||||||
|
- `/error/500` - Internal Server Error
|
||||||
|
- `/error/502` - Bad Gateway
|
||||||
|
- `/error/503` - Service Unavailable
|
||||||
|
- `*` - 404 Not Found (catch-all)
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Für File-Uploads (FormData)
|
||||||
|
Verwende `apiClient` für multipart/form-data Uploads:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import apiClient from '../Utils/apiClient';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
apiClient.post('/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Success handling
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Automatische Weiterleitung zu Error-Page bei 403, 500, 502, 503
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Für JSON-API-Aufrufe
|
||||||
|
Verwende `apiFetch` oder Helper-Funktionen:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { apiFetch, apiGet, apiPost } from '../Utils/apiFetch';
|
||||||
|
|
||||||
|
// GET Request
|
||||||
|
const data = await apiGet('/api/groups');
|
||||||
|
|
||||||
|
// POST Request
|
||||||
|
const result = await apiPost('/api/groups', { name: 'Test' });
|
||||||
|
|
||||||
|
// Custom Request
|
||||||
|
const response = await apiFetch('/api/groups/123', {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Error Codes
|
||||||
|
|
||||||
|
Das Backend liefert bereits folgende Statuscodes:
|
||||||
|
|
||||||
|
- **403**: CSRF-Fehler, fehlende Admin-Session, public host auf internal routes
|
||||||
|
- **500**: Datenbank-Fehler, Upload-Fehler, Migration-Fehler
|
||||||
|
- **502**: Nicht implementiert (wird von Reverse Proxy geliefert)
|
||||||
|
- **503**: Nicht implementiert (für Wartungsmodus vorgesehen)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Um die Error-Pages zu testen:
|
||||||
|
|
||||||
|
1. **403**: Versuche ohne Login auf Admin-Routen zuzugreifen
|
||||||
|
2. **404**: Navigiere zu einer nicht existierenden Route (z.B. `/nicht-vorhanden`)
|
||||||
|
3. **500**: Simuliere Backend-Fehler
|
||||||
|
4. **502/503**: Manuell über `/error/502` oder `/error/503` aufrufen
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ App.js │
|
||||||
|
│ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ ErrorBoundary │ │
|
||||||
|
│ │ (fängt React-Fehler) │ │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Router │ │ │
|
||||||
|
│ │ │ ┌───────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ Routes │ │ │ │
|
||||||
|
│ │ │ │ - / │ │ │ │
|
||||||
|
│ │ │ │ - /error/403 │ │ │ │
|
||||||
|
│ │ │ │ - /error/500 │ │ │ │
|
||||||
|
│ │ │ │ - /error/502 │ │ │ │
|
||||||
|
│ │ │ │ - /error/503 │ │ │ │
|
||||||
|
│ │ │ │ - * (404) │ │ │ │
|
||||||
|
│ │ │ └───────────────────────────┘ │ │ │
|
||||||
|
│ │ └─────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ API Layer │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ apiClient.js (axios) │
|
||||||
|
│ - FormData/File-Uploads │
|
||||||
|
│ - Response Interceptor │
|
||||||
|
│ │
|
||||||
|
│ apiFetch.js (fetch) │
|
||||||
|
│ - JSON-API-Aufrufe │
|
||||||
|
│ - Error-Response-Handling │
|
||||||
|
│ │
|
||||||
|
│ adminApi.js (fetch + CSRF) │
|
||||||
|
│ - Admin-Authentifizierung │
|
||||||
|
│ - CSRF-Token-Management │
|
||||||
|
│ - Nicht migriert (eigenes System) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Error-Flow:
|
||||||
|
HTTP 403/500/502/503 → Interceptor/Handler → window.location.href → Error-Page
|
||||||
|
React Error → ErrorBoundary → 500-Page
|
||||||
|
```
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.1.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.1.0",
|
"version": "2.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.1.0",
|
"version": "2.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject",
|
||||||
|
"version": "cd .. && ./scripts/sync-version.sh && git add -A"
|
||||||
},
|
},
|
||||||
"proxy": "http://backend-dev:5000",
|
"proxy": "http://backend-dev:5000",
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@
|
||||||
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/logo-180.png" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700&display=swap" rel="stylesheet">
|
|
||||||
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
|
<script src="https://kit.fontawesome.com/fc1a4a5a71.js" crossorigin="anonymous"></script>
|
||||||
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
|
<link rel="stylesheet" href="@sweetalert2/theme-material-ui/material-ui.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@100&display=swap" rel="stylesheet">
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,205 @@
|
||||||
/* Main shared styles for cards, buttons, modals used across pages */
|
/* Main shared styles for cards, buttons, modals used across pages */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY - Zentrale Schrift-Definitionen
|
||||||
|
============================================ */
|
||||||
|
body {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
color: #333333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, .h1 {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, .h2 {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3, .h3 {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, .text-body {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666666;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-subtitle {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-small {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LAYOUT & CONTAINERS
|
||||||
|
============================================ */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PAGE HEADERS
|
||||||
|
============================================ */
|
||||||
|
.page-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666666;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center-block {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing utilities */
|
||||||
|
.mt-1 { margin-top: 8px; }
|
||||||
|
.mt-2 { margin-top: 16px; }
|
||||||
|
.mt-3 { margin-top: 24px; }
|
||||||
|
.mt-4 { margin-top: 32px; }
|
||||||
|
.mb-1 { margin-bottom: 8px; }
|
||||||
|
.mb-2 { margin-bottom: 16px; }
|
||||||
|
.mb-3 { margin-bottom: 24px; }
|
||||||
|
.mb-4 { margin-bottom: 32px; }
|
||||||
|
.p-2 { padding: 16px; }
|
||||||
|
.p-3 { padding: 24px; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SUCCESS BOX (Upload Success)
|
||||||
|
============================================ */
|
||||||
|
.success-box {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 20px rgba(76, 175, 80, 0.4);
|
||||||
|
animation: slideIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box p {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box-highlight {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
EXISTING STYLES BELOW
|
||||||
|
============================================ */
|
||||||
|
|
||||||
/* Page-specific styles for GroupsOverviewPage */
|
/* Page-specific styles for GroupsOverviewPage */
|
||||||
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
|
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
|
||||||
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
|
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
|
||||||
.header-title { font-family: roboto; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
.header-title { font-family: 'Open Sans', sans-serif; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
|
||||||
.header-subtitle { font-family: roboto; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
.header-subtitle { font-family: 'Open Sans', sans-serif; font-size: 16px; color: #666666; margin-bottom: 20px; }
|
||||||
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
@media (max-width:800px) { .nav__links, .cta { display:none; } }
|
||||||
|
|
||||||
/* Page-specific styles for ModerationPage */
|
/* Page-specific styles for ModerationPage */
|
||||||
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
.moderation-page { font-family: 'Open Sans', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; }
|
h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
||||||
|
p { font-family: 'Open Sans', sans-serif; color:#555; line-height:1.6; }
|
||||||
|
.moderation-content h1 { font-family: 'Open Sans', sans-serif; text-align:left; color:#333; margin-bottom:30px; }
|
||||||
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
|
||||||
.moderation-error { color:#dc3545; }
|
.moderation-error { color:#dc3545; }
|
||||||
|
|
||||||
|
|
@ -50,7 +240,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:0.85rem; transition:background-color 0.2s; flex:1; min-width:80px; }
|
.btn { padding:12px 30px; border:none; border-radius:6px; cursor:pointer; font-size:16px; transition:background-color 0.2s; min-width:80px; }
|
||||||
.btn-secondary { background:#6c757d; color:white; }
|
.btn-secondary { background:#6c757d; color:white; }
|
||||||
.btn-secondary:hover { background:#5a6268; }
|
.btn-secondary:hover { background:#5a6268; }
|
||||||
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
|
.btn-outline-secondary { background:transparent; border:1px solid #6c757d; color:#6c757d; }
|
||||||
|
|
@ -61,7 +251,6 @@
|
||||||
.btn-warning:hover { background:#e0a800; }
|
.btn-warning:hover { background:#e0a800; }
|
||||||
.btn-danger { background:#dc3545; color:white; }
|
.btn-danger { background:#dc3545; color:white; }
|
||||||
.btn-danger:hover { background:#c82333; }
|
.btn-danger:hover { background:#c82333; }
|
||||||
.btn-sm { padding:4px 8px; font-size:0.75rem; min-width:auto; }
|
|
||||||
.btn:disabled { opacity:0.65; cursor:not-allowed; }
|
.btn:disabled { opacity:0.65; cursor:not-allowed; }
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
|
|
@ -102,3 +291,32 @@
|
||||||
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
|
.admin-auth-card { width:100%; max-width:420px; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
|
||||||
.admin-auth-form { width:100%; }
|
.admin-auth-form { width:100%; }
|
||||||
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }
|
.admin-auth-error { max-width:420px; background:#fff3f3; border:1px solid #ffcdd2; padding:24px; border-radius:12px; text-align:center; color:#b71c1c; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MATERIAL-UI OVERRIDES - Globale Schriftart
|
||||||
|
============================================ */
|
||||||
|
/* TextField, Input, Textarea */
|
||||||
|
.MuiTextField-root input,
|
||||||
|
.MuiTextField-root textarea,
|
||||||
|
.MuiInputBase-root,
|
||||||
|
.MuiInputBase-input,
|
||||||
|
.MuiOutlinedInput-input {
|
||||||
|
font-family: 'Open Sans', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.MuiFormLabel-root,
|
||||||
|
.MuiInputLabel-root,
|
||||||
|
.MuiTypography-root {
|
||||||
|
font-family: 'Open Sans', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.MuiButton-root {
|
||||||
|
font-family: 'Open Sans', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox Labels */
|
||||||
|
.MuiFormControlLabel-label {
|
||||||
|
font-family: 'Open Sans', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,123 @@
|
||||||
|
import React, { lazy, Suspense } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
|
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
|
||||||
|
import { getHostConfig } from './Utils/hostDetection.js';
|
||||||
|
import ErrorBoundary from './Components/ComponentUtils/ErrorBoundary.js';
|
||||||
|
|
||||||
// Pages
|
// Always loaded (public + internal)
|
||||||
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
import MultiUploadPage from './Components/Pages/MultiUploadPage';
|
||||||
import SlideshowPage from './Components/Pages/SlideshowPage';
|
|
||||||
import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage';
|
|
||||||
import ModerationGroupsPage from './Components/Pages/ModerationGroupsPage';
|
|
||||||
import ModerationGroupImagesPage from './Components/Pages/ModerationGroupImagesPage';
|
|
||||||
import PublicGroupImagesPage from './Components/Pages/PublicGroupImagesPage';
|
|
||||||
import ManagementPortalPage from './Components/Pages/ManagementPortalPage';
|
import ManagementPortalPage from './Components/Pages/ManagementPortalPage';
|
||||||
import FZF from './Components/Pages/404Page.js'
|
import ErrorPage from './Components/Pages/ErrorPage.js';
|
||||||
|
|
||||||
|
// Lazy loaded (internal only) - Code Splitting für Performance
|
||||||
|
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
|
||||||
|
const GroupsOverviewPage = lazy(() => import('./Components/Pages/GroupsOverviewPage'));
|
||||||
|
const PublicGroupImagesPage = lazy(() => import('./Components/Pages/PublicGroupImagesPage'));
|
||||||
|
const ModerationGroupsPage = lazy(() => import('./Components/Pages/ModerationGroupsPage'));
|
||||||
|
const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/ModerationGroupImagesPage'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected Route Component
|
||||||
|
* Shows 403 page if accessed from public host
|
||||||
|
*/
|
||||||
|
const ProtectedRoute = ({ children }) => {
|
||||||
|
const hostConfig = getHostConfig();
|
||||||
|
|
||||||
|
if (hostConfig.isPublic) {
|
||||||
|
// Show 403 - feature not available on public
|
||||||
|
return <ErrorPage errorCode="403" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading Fallback für Code Splitting
|
||||||
|
*/
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem'
|
||||||
|
}}>
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Lädt...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const hostConfig = getHostConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSessionProvider>
|
<ErrorBoundary>
|
||||||
<Router>
|
<AdminSessionProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route path="/" exact element={<MultiUploadPage />} />
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Route path="/slideshow" element={<SlideshowPage />} />
|
<Routes>
|
||||||
<Route path="/groups/:groupId" element={<PublicGroupImagesPage />} />
|
{/* Public Routes - immer verfügbar */}
|
||||||
<Route path="/groups" element={<GroupsOverviewPage />} />
|
<Route path="/" element={<MultiUploadPage />} />
|
||||||
<Route path="/moderation" exact element={<ModerationGroupsPage />} />
|
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||||
<Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} />
|
|
||||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
{/* Error Pages */}
|
||||||
<Route path="*" element={<FZF />} />
|
<Route path="/error/403" element={<ErrorPage errorCode="403" />} />
|
||||||
</Routes>
|
<Route path="/error/404" element={<ErrorPage errorCode="404" />} />
|
||||||
|
<Route path="/error/500" element={<ErrorPage errorCode="500" />} />
|
||||||
|
<Route path="/error/502" element={<ErrorPage errorCode="502" />} />
|
||||||
|
<Route path="/error/503" element={<ErrorPage errorCode="503" />} />
|
||||||
|
|
||||||
|
{/* Internal Only Routes - geschützt durch ProtectedRoute */}
|
||||||
|
<Route
|
||||||
|
path="/slideshow"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SlideshowPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/groups/:groupId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PublicGroupImagesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/groups"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GroupsOverviewPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/moderation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ModerationGroupsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/moderation/groups/:groupId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ModerationGroupImagesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 404 / Not Found */}
|
||||||
|
<Route path="*" element={<ErrorPage errorCode="404" />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</Router>
|
</Router>
|
||||||
</AdminSessionProvider>
|
</AdminSessionProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
.consent-filter-container {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-title {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter {
|
||||||
|
min-width: 250px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-label:hover {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-filter-checkbox {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import React from 'react';
|
||||||
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import './ConsentFilter.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConsentFilter Component
|
||||||
|
* Displays checkboxes for filtering groups by consent type
|
||||||
|
*
|
||||||
|
* @param {Object} filters - Current filter state { workshop, facebook, instagram, tiktok }
|
||||||
|
* @param {Function} onChange - Callback when filter changes
|
||||||
|
* @param {Array} platforms - Available social media platforms from API
|
||||||
|
*/
|
||||||
|
const ConsentFilter = ({ filters, onChange, platforms = [] }) => {
|
||||||
|
const handleCheckboxChange = (filterName, checked) => {
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
[filterName]: checked
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Platform mapping for display names
|
||||||
|
const platformLabels = {
|
||||||
|
workshop: 'Werkstatt',
|
||||||
|
facebook: 'Facebook',
|
||||||
|
instagram: 'Instagram',
|
||||||
|
tiktok: 'TikTok'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="consent-filter-container">
|
||||||
|
<h2 className="consent-filter-title">Filter</h2>
|
||||||
|
<fieldset className="consent-filter">
|
||||||
|
<legend className="consent-filter-legend">
|
||||||
|
<FilterListIcon className="filter-icon" />
|
||||||
|
Consent-Filter
|
||||||
|
</legend>
|
||||||
|
<div className="consent-filter-options">
|
||||||
|
<label className="consent-filter-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.workshop}
|
||||||
|
onChange={(e) => handleCheckboxChange('workshop', e.target.checked)}
|
||||||
|
className="consent-filter-checkbox"
|
||||||
|
/>
|
||||||
|
{platformLabels.workshop}
|
||||||
|
</label>
|
||||||
|
<label className="consent-filter-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.facebook}
|
||||||
|
onChange={(e) => handleCheckboxChange('facebook', e.target.checked)}
|
||||||
|
className="consent-filter-checkbox"
|
||||||
|
/>
|
||||||
|
{platformLabels.facebook}
|
||||||
|
</label>
|
||||||
|
<label className="consent-filter-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.instagram}
|
||||||
|
onChange={(e) => handleCheckboxChange('instagram', e.target.checked)}
|
||||||
|
className="consent-filter-checkbox"
|
||||||
|
/>
|
||||||
|
{platformLabels.instagram}
|
||||||
|
</label>
|
||||||
|
<label className="consent-filter-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.tiktok}
|
||||||
|
onChange={(e) => handleCheckboxChange('tiktok', e.target.checked)}
|
||||||
|
className="consent-filter-checkbox"
|
||||||
|
/>
|
||||||
|
{platformLabels.tiktok}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsentFilter;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box, Alert, Typography } from '@mui/material';
|
import { Box, Alert, Typography } from '@mui/material';
|
||||||
import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes';
|
import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages consents with save functionality
|
* Manages consents with save functionality
|
||||||
|
|
@ -148,7 +149,7 @@ function ConsentManager({
|
||||||
|
|
||||||
// Save each change
|
// Save each change
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const res = await fetch(`/api/manage/${token}/consents`, {
|
const res = await apiFetch(`/api/manage/${token}/consents`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(change)
|
body: JSON.stringify(change)
|
||||||
|
|
@ -235,11 +236,11 @@ function ConsentManager({
|
||||||
{/* Email Hint after successful save */}
|
{/* Email Hint after successful save */}
|
||||||
{showEmailHint && successMessage && (
|
{showEmailHint && successMessage && (
|
||||||
<Alert severity="info" sx={{ mt: 2 }}>
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
<strong>Wichtig:</strong> Bitte senden Sie jetzt eine E-Mail an{' '}
|
<strong>Wichtig:</strong> Bitte sende eine E-Mail an{' '}
|
||||||
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
|
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
|
||||||
info@hobbyhimmel.de
|
info@hobbyhimmel.de
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
mit Ihrer Gruppen-ID, um die Löschung Ihrer Bilder auf den Social Media Plattformen anzufordern.
|
mit Deiner Gruppen-ID, um die Löschung Deiner Bilder auf den Social Media Plattformen anzufordern.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #808080;
|
color: #808080;
|
||||||
font-family: "Roboto", sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
font-weight: lighter;
|
font-weight: lighter;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
|
|
@ -23,7 +23,7 @@ footer {
|
||||||
footer a {
|
footer a {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #777;
|
color: #777;
|
||||||
font-family: "Roboto", sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
font-weight: lighter;
|
font-weight: lighter;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,9 @@
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ImageGalleryCard - No preview state */
|
/* ImageGalleryCard - No preview state */
|
||||||
|
|
@ -185,7 +187,7 @@
|
||||||
|
|
||||||
.image-gallery-title {
|
.image-gallery-title {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -237,13 +239,18 @@
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0,0,0,0.7);
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 8px 12px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
opacity: 0;
|
opacity: 1; /* Always visible on mobile */
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s, background 0.2s;
|
||||||
|
touch-action: none; /* Prevent scrolling when touching handle */
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover {
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-gallery-card.reorderable:hover .drag-handle {
|
.image-gallery-card.reorderable:hover .drag-handle {
|
||||||
|
|
@ -294,7 +301,7 @@
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ header {
|
||||||
.logo {
|
.logo {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
color: #ECF0F1;
|
color: #ECF0F1;
|
||||||
font-family: 'Montserrat', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
@ -33,7 +33,7 @@ header {
|
||||||
.nav__links a,
|
.nav__links a,
|
||||||
.cta,
|
.cta,
|
||||||
.overlay__content a {
|
.overlay__content a {
|
||||||
font-family: "Montserrat", sans-serif;
|
font-family: "Open Sans", sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #edf0f1;
|
color: #edf0f1;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -77,6 +77,38 @@ header {
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
display: none;
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu span {
|
||||||
|
width: 28px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: #edf0f1;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu:focus-visible {
|
||||||
|
outline: 2px solid #edf0f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu--open span:nth-child(1) {
|
||||||
|
transform: translateY(9px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu--open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu--open span:nth-child(3) {
|
||||||
|
transform: translateY(-9px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|
@ -121,6 +153,8 @@ header {
|
||||||
font-size: 60px;
|
font-size: 60px;
|
||||||
color: #edf0f1;
|
color: #edf0f1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-height: 450px) {
|
@media screen and (max-height: 450px) {
|
||||||
|
|
@ -140,6 +174,6 @@ header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
display: initial;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Button } from '@mui/material';
|
||||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||||
import Swal from 'sweetalert2';
|
import Swal from 'sweetalert2';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete group button with confirmation dialog
|
* Delete group button with confirmation dialog
|
||||||
|
|
@ -41,7 +42,7 @@ function DeleteGroupButton({ token, groupName = 'diese Gruppe' }) {
|
||||||
try {
|
try {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
|
||||||
const res = await fetch(`/api/manage/${token}`, {
|
const res = await apiFetch(`/api/manage/${token}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/* ErrorAnimation Component Styles */
|
||||||
|
|
||||||
|
.error-animation-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
position: relative;
|
||||||
|
perspective: 1000px;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-rotor {
|
||||||
|
display: inline-block;
|
||||||
|
transform-origin: center;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
will-change: transform;
|
||||||
|
animation: errorRotateY 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-logo {
|
||||||
|
display: block;
|
||||||
|
width: 400px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-logo #g136 {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
will-change: transform;
|
||||||
|
animation: errorRotateSegments 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorRotateY {
|
||||||
|
0% {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotateY(90deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorRotateSegments {
|
||||||
|
0% {
|
||||||
|
transform: rotate3d(1, -1, 0, 0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate3d(1, -1, 0, 90deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate3d(1, -1, 0, 0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Sizing */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.error-logo {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-animation-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.error-logo {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-animation-container {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ErrorAnimation.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorAnimation Component
|
||||||
|
* Zeigt eine animierte Wolke mit einem Fehlercode in Sieben-Segment-Anzeige
|
||||||
|
*
|
||||||
|
* @param {string} errorCode - Der anzuzeigende Fehlercode (z.B. "404", "403", "500")
|
||||||
|
*/
|
||||||
|
const ErrorAnimation = ({ errorCode = "404" }) => {
|
||||||
|
// Sieben-Segment-Mapping: welche Segmente für welche Ziffer leuchten
|
||||||
|
const segmentPatterns = {
|
||||||
|
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||||
|
'1': ['b', 'c'],
|
||||||
|
'2': ['a', 'b', 'd', 'e', 'g'],
|
||||||
|
'3': ['a', 'b', 'c', 'd', 'g'],
|
||||||
|
'4': ['b', 'c', 'f', 'g'],
|
||||||
|
'5': ['a', 'c', 'd', 'f', 'g'],
|
||||||
|
'6': ['a', 'c', 'd', 'e', 'f', 'g'],
|
||||||
|
'7': ['a', 'b', 'c'],
|
||||||
|
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
||||||
|
'9': ['a', 'b', 'c', 'd', 'f', 'g']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Segment-Zuordnung zu Polygon-IDs (Position im Array = Segment)
|
||||||
|
const segmentOrder = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
||||||
|
|
||||||
|
// Fehlercode auf max 3 Ziffern begrenzen und mit Leerzeichen auffüllen
|
||||||
|
const displayCode = errorCode.toString().padStart(3, ' ').slice(0, 3);
|
||||||
|
const digits = displayCode.split('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt die Füllfarbe für ein Segment
|
||||||
|
* @param {string} digit - Die Ziffer (0-9 oder Leerzeichen)
|
||||||
|
* @param {string} segment - Das Segment (a-g)
|
||||||
|
* @returns {string} - Hex-Farbcode
|
||||||
|
*/
|
||||||
|
const getSegmentColor = (digit, segment) => {
|
||||||
|
if (digit === ' ') return '#ffffff'; // Leerzeichen = alle aus
|
||||||
|
const pattern = segmentPatterns[digit];
|
||||||
|
return pattern && pattern.includes(segment) ? '#76b043' : '#ffffff';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert Polygon-Elemente für eine Ziffer
|
||||||
|
* @param {number} digitIndex - Position der Ziffer (0-2)
|
||||||
|
* @returns {JSX.Element[]} - Array von Polygon-Elementen
|
||||||
|
*/
|
||||||
|
const renderDigit = (digitIndex) => {
|
||||||
|
const digit = digits[digitIndex];
|
||||||
|
// Mapping: digitIndex 0 (links) = g1800, digitIndex 1 (mitte) = g1758, digitIndex 2 (rechts) = g1782
|
||||||
|
const baseIds = {
|
||||||
|
0: { group: 'g1800', polygons: ['polygon1786', 'polygon1788', 'polygon1790', 'polygon1792', 'polygon1794', 'polygon1796', 'polygon1798'] },
|
||||||
|
1: { group: 'g1758', polygons: ['polygon1573', 'polygon1575', 'polygon1577', 'polygon1579', 'polygon1581', 'polygon1583', 'polygon1585'] },
|
||||||
|
2: { group: 'g1782', polygons: ['polygon1768', 'polygon1770', 'polygon1772', 'polygon1774', 'polygon1776', 'polygon1778', 'polygon1780'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const transforms = {
|
||||||
|
0: 'translate(47.970487,-113.03641)',
|
||||||
|
1: 'translate(113.66502,-113.03641)',
|
||||||
|
2: 'translate(179.35956,-113.03641)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const { group, polygons } = baseIds[digitIndex];
|
||||||
|
const transform = transforms[digitIndex];
|
||||||
|
|
||||||
|
const polyPoints = [
|
||||||
|
'20,20 10,10 20,0 60,0 70,10 60,20', // a (oben)
|
||||||
|
'60,20 70,10 80,20 80,40 70,50 60,40', // b (rechts oben)
|
||||||
|
'80,60 80,80 70,90 60,80 60,60 70,50', // c (rechts unten)
|
||||||
|
'20,80 60,80 70,90 60,100 20,100 10,90', // d (unten)
|
||||||
|
'10,80 0,90 -10,80 -10,60 0,50 10,60', // e (links unten)
|
||||||
|
'10,20 10,40 0,50 -10,40 -10,20 0,10', // f (links oben)
|
||||||
|
'20,60 10,50 20,40 60,40 70,50 60,60' // g (mitte)
|
||||||
|
];
|
||||||
|
|
||||||
|
const polyTransforms = [
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,117.69293,49.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,119.69293,51.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,119.69293,55.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,117.69293,57.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,122.44524,55.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,122.44524,51.286325)',
|
||||||
|
'matrix(0.67523047,0,0,0.67523047,117.69293,53.286325)'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g id={group} transform={transform} key={digitIndex}>
|
||||||
|
{segmentOrder.map((segment, idx) => (
|
||||||
|
<polygon
|
||||||
|
key={polygons[idx]}
|
||||||
|
id={polygons[idx]}
|
||||||
|
points={polyPoints[idx]}
|
||||||
|
transform={polyTransforms[idx]}
|
||||||
|
style={{
|
||||||
|
fill: getSegmentColor(digit, segment),
|
||||||
|
fillOpacity: 1,
|
||||||
|
stroke: 'none',
|
||||||
|
strokeWidth: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-animation-container">
|
||||||
|
<div className="error-rotor">
|
||||||
|
<svg
|
||||||
|
className="error-logo"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 289.40499 170.09499"
|
||||||
|
>
|
||||||
|
{/* Wolke (g561) - bleibt immer gleich */}
|
||||||
|
<g id="g561" style={{ display: 'inline' }}>
|
||||||
|
<path
|
||||||
|
id="path1353"
|
||||||
|
style={{ display: 'inline', fill: '#48484a' }}
|
||||||
|
d="M 138.80469 0 C 97.587768 0 63.224812 29.321264 55.423828 68.242188 C 53.972832 68.119188 52.50934 68.042969 51.027344 68.042969 C 22.8464 68.042969 0 90.887413 0 119.06836 C 0 147.2483 22.8474 170.0957 51.027344 170.0957 C 65.865314 170.0957 210.51721 170.09375 225.61719 170.09375 C 260.84611 170.09375 289.4043 142.40467 289.4043 107.17773 C 289.4053 71.952807 260.84808 43.392578 225.61914 43.392578 C 221.50914 43.392578 217.49456 43.796064 213.60156 44.539062 C 199.2046 18.011166 171.10863 0 138.80469 0 z M 171.96289 40.238281 A 39.540237 71.54811 46.312638 0 1 192.97852 47.357422 A 39.540237 71.54811 46.312638 0 1 170.08984 124.95117 A 39.540237 71.54811 46.312638 0 1 90.582031 147.28711 A 39.540237 71.54811 46.312638 0 1 113.4707 69.695312 A 39.540237 71.54811 46.312638 0 1 171.96289 40.238281 z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Sieben-Segment-Anzeige (g136) - dynamisch generiert */}
|
||||||
|
<g id="g136">
|
||||||
|
<g id="siebensegment" transform="matrix(0.46393276,-0.46393277,0.46393277,0.46393276,33.958225,228.89983)" style={{ display: 'inline' }}>
|
||||||
|
{renderDigit(0)}
|
||||||
|
{renderDigit(1)}
|
||||||
|
{renderDigit(2)}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ErrorAnimation.propTypes = {
|
||||||
|
errorCode: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.number
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorAnimation;
|
||||||
38
frontend/src/Components/ComponentUtils/ErrorBoundary.js
Normal file
38
frontend/src/Components/ComponentUtils/ErrorBoundary.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ErrorPage from '../Pages/ErrorPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error Boundary Component
|
||||||
|
* Fängt React-Fehler ab und zeigt die 500-Error-Page an
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null, errorInfo: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
// Update state so the next render will show the fallback UI
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
// Log error details for debugging
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// Render 500 Error Page
|
||||||
|
return <ErrorPage errorCode="500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import packageJson from '../../../package.json'
|
||||||
|
|
||||||
import './Css/Footer.css'
|
import './Css/Footer.css'
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
const version = window._env_?.APP_VERSION || '1.1.0';
|
const version = packageJson.version;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer>
|
<footer>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
|
||||||
import DescriptionInput from './MultiUpload/DescriptionInput';
|
import DescriptionInput from './MultiUpload/DescriptionInput';
|
||||||
import { adminRequest } from '../../services/adminApi';
|
import { adminRequest } from '../../services/adminApi';
|
||||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages group metadata with save functionality
|
* Manages group metadata with save functionality
|
||||||
|
|
@ -76,7 +77,7 @@ function GroupMetadataEditor({
|
||||||
if (isModerateMode) {
|
if (isModerateMode) {
|
||||||
await adminRequest(endpoint, method, metadata);
|
await adminRequest(endpoint, method, metadata);
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(endpoint, {
|
const res = await apiFetch(endpoint, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(metadata)
|
body: JSON.stringify(metadata)
|
||||||
|
|
@ -143,7 +144,7 @@ function GroupMetadataEditor({
|
||||||
>
|
>
|
||||||
{/* Component Header */}
|
{/* Component Header */}
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
📝 Projekt-Informationen
|
Projekt-Informationen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<DescriptionInput
|
<DescriptionInput
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import '../Css/Navbar.css'
|
import '../Css/Navbar.css'
|
||||||
|
|
@ -9,22 +9,67 @@ import { Lock as LockIcon } from '@mui/icons-material';
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isManagementPage = location.pathname.startsWith('/manage/');
|
const isManagementPage = location.pathname.startsWith('/manage/');
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const toggleMenu = () => setMenuOpen(prev => !prev);
|
||||||
|
const closeMenu = () => setMenuOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header>
|
<>
|
||||||
<div className="logo"><NavLink className="logo" exact to="/"><img src={logo} className="imageNav" alt="Logo"/><p className="logo">Upload your Project Images</p></NavLink></div>
|
<header>
|
||||||
<nav>
|
<div className="logo">
|
||||||
<ul className="nav__links">
|
<NavLink className="logo" exact to="/">
|
||||||
<li><NavLink to="/groups" activeClassName="active">Groups</NavLink></li>
|
<img src={logo} className="imageNav" alt="Logo" />
|
||||||
<li><NavLink to="/moderation" activeClassName="active"><LockIcon style={{ fontSize: 18, verticalAlign: 'text-bottom', marginRight: 6 }} aria-hidden="true" />Moderation</NavLink></li>
|
<p className="logo">Upload your Project Images</p>
|
||||||
<li><NavLink className="cta" exact to="/" activeClassName="active">Upload</NavLink></li>
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<ul className="nav__links">
|
||||||
|
<li><NavLink to="/groups" activeClassName="active">Groups</NavLink></li>
|
||||||
|
<li><NavLink to="/moderation" activeClassName="active"><LockIcon style={{ fontSize: 18, verticalAlign: 'text-bottom', marginRight: 6 }} aria-hidden="true" />Moderation</NavLink></li>
|
||||||
|
<li><NavLink className="cta" exact to="/" activeClassName="active">Upload</NavLink></li>
|
||||||
|
{isManagementPage && (
|
||||||
|
<li><NavLink className="cta" to={location.pathname} activeClassName="active">Mein Upload</NavLink></li>
|
||||||
|
)}
|
||||||
|
<li><a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer">About</a></li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`menu${menuOpen ? ' menu--open' : ''}`}
|
||||||
|
onClick={toggleMenu}
|
||||||
|
aria-label="Navigation umschalten"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls="mobile-nav"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
id="mobile-nav"
|
||||||
|
className={`overlay${menuOpen ? ' overlay--active' : ''}`}
|
||||||
|
aria-hidden={!menuOpen}
|
||||||
|
>
|
||||||
|
<button type="button" className="close" onClick={closeMenu} aria-label="Navigation schließen">×</button>
|
||||||
|
<div className="overlay__content">
|
||||||
|
<NavLink to="/groups" activeClassName="active" onClick={closeMenu}>Groups</NavLink>
|
||||||
|
<NavLink to="/moderation" activeClassName="active" onClick={closeMenu}>
|
||||||
|
<LockIcon style={{ fontSize: 24, verticalAlign: 'text-bottom', marginRight: 12 }} aria-hidden="true" />Moderation
|
||||||
|
</NavLink>
|
||||||
|
<NavLink exact to="/" activeClassName="active" onClick={closeMenu}>Upload</NavLink>
|
||||||
{isManagementPage && (
|
{isManagementPage && (
|
||||||
<li><NavLink className="cta" to={location.pathname} activeClassName="active">Mein Upload</NavLink></li>
|
<NavLink to={location.pathname} activeClassName="active" onClick={closeMenu}>Mein Upload</NavLink>
|
||||||
)}
|
)}
|
||||||
<li><a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer">About</a></li>
|
<a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer" onClick={closeMenu}>About</a>
|
||||||
</ul>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,59 @@
|
||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import '../Css/Navbar.css'
|
import '../Css/Navbar.css'
|
||||||
|
|
||||||
import logo from '../../../Images/logo.png'
|
import logo from '../../../Images/logo.png'
|
||||||
import { Lock as LockIcon } from '@mui/icons-material';
|
|
||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isManagementPage = location.pathname.startsWith('/manage/');
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const toggleMenu = () => setMenuOpen(prev => !prev);
|
||||||
|
const closeMenu = () => setMenuOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header>
|
<>
|
||||||
<div className="logo"><NavLink className="logo" exact to="/"><img src={logo} className="imageNav" alt="Logo"/><p className="logo">Upload your Project Images</p></NavLink></div>
|
<header>
|
||||||
<nav>
|
<div className="logo">
|
||||||
<ul className="nav__links">
|
<NavLink className="logo" exact to="/">
|
||||||
<li><a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer">About</a></li>
|
<img src={logo} className="imageNav" alt="Logo" />
|
||||||
</ul>
|
<p className="logo">Upload your Project Images</p>
|
||||||
</nav>
|
</NavLink>
|
||||||
</header>
|
</div>
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<ul className="nav__links">
|
||||||
|
<li><a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer">About</a></li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`menu${menuOpen ? ' menu--open' : ''}`}
|
||||||
|
onClick={toggleMenu}
|
||||||
|
aria-label="Navigation umschalten"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls="mobile-nav-upload"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
id="mobile-nav-upload"
|
||||||
|
className={`overlay${menuOpen ? ' overlay--active' : ''}`}
|
||||||
|
aria-hidden={!menuOpen}
|
||||||
|
>
|
||||||
|
<button type="button" className="close" onClick={closeMenu} aria-label="Navigation schließen">×</button>
|
||||||
|
<div className="overlay__content">
|
||||||
|
<a href="https://gitea.lan.hobbyhimmel.de/hobbyhimmel/Project-Image-Uploader" target="_blank" rel="noopener noreferrer" onClick={closeMenu}>About</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import Swal from 'sweetalert2';
|
||||||
import ImageGallery from './ImageGallery';
|
import ImageGallery from './ImageGallery';
|
||||||
import { adminRequest } from '../../services/adminApi';
|
import { adminRequest } from '../../services/adminApi';
|
||||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages image descriptions with save functionality
|
* Manages image descriptions with save functionality
|
||||||
|
|
@ -49,7 +50,7 @@ function ImageDescriptionManager({
|
||||||
if (mode === 'moderate') {
|
if (mode === 'moderate') {
|
||||||
await adminRequest(endpoint, 'DELETE');
|
await adminRequest(endpoint, 'DELETE');
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(endpoint, {
|
const res = await apiFetch(endpoint, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -138,7 +139,7 @@ function ImageDescriptionManager({
|
||||||
if (mode === 'moderate') {
|
if (mode === 'moderate') {
|
||||||
await adminRequest(endpoint, method, { descriptions });
|
await adminRequest(endpoint, method, { descriptions });
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(endpoint, {
|
const res = await apiFetch(endpoint, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ descriptions })
|
body: JSON.stringify({ descriptions })
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors
|
useSensors
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
|
@ -34,11 +35,17 @@ const ImageGallery = ({
|
||||||
imageDescriptions = {},
|
imageDescriptions = {},
|
||||||
onDescriptionChange = null
|
onDescriptionChange = null
|
||||||
}) => {
|
}) => {
|
||||||
// Sensors for drag and drop (touch-friendly)
|
// Sensors for drag and drop (desktop + mobile optimized)
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 0, // No delay - allow immediate dragging
|
||||||
|
tolerance: 0, // No tolerance - precise control
|
||||||
|
},
|
||||||
|
}),
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8, // Require 8px movement before drag starts
|
distance: 5, // Require 5px movement before drag starts (desktop)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
|
|
|
||||||
|
|
@ -221,71 +221,30 @@ const ImageGalleryCard = ({
|
||||||
mode === 'preview' ? (
|
mode === 'preview' ? (
|
||||||
// Preview mode actions (for upload preview)
|
// Preview mode actions (for upload preview)
|
||||||
<>
|
<>
|
||||||
<button
|
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑️ Löschen</button>
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={() => onDelete(itemId)}
|
|
||||||
>
|
|
||||||
🗑️ Löschen
|
|
||||||
</button>
|
|
||||||
{!isEditMode ? (
|
{!isEditMode ? (
|
||||||
<button
|
<button className="btn btn-primary" onClick={() => onEditMode?.(true)}>✏️ Edit </button>
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => onEditMode?.(true)}
|
|
||||||
>
|
|
||||||
✏️ Edit
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button className="btn btn-success" onClick={() => onEditMode?.(false)}>✅ Fertig</button>
|
||||||
className="btn btn-success btn-sm"
|
|
||||||
onClick={() => onEditMode?.(false)}
|
|
||||||
>
|
|
||||||
✅ Fertig
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Moderation mode actions (for existing groups)
|
// Moderation mode actions (for existing groups)
|
||||||
<>
|
<>
|
||||||
<button
|
<button className="btn btn-secondary" onClick={() => onViewImages(item)}>✏️ Gruppe editieren</button>
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => onViewImages(item)}
|
|
||||||
>
|
|
||||||
✏️ Gruppe editieren
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<button
|
<button className="btn btn-success" onClick={() => onApprove(itemId, true)}>✅ Freigeben</button>
|
||||||
className="btn btn-success"
|
|
||||||
onClick={() => onApprove(itemId, true)}
|
|
||||||
>
|
|
||||||
✅ Freigeben
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button className="btn btn-warning" onClick={() => onApprove(itemId, false)}>⏸️ Sperren</button>
|
||||||
className="btn btn-warning"
|
|
||||||
onClick={() => onApprove(itemId, false)}
|
|
||||||
>
|
|
||||||
⏸️ Sperren
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button className="btn btn-danger" onClick={() => onDelete(itemId)}>🗑️ Löschen</button>
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={() => onDelete(itemId)}
|
|
||||||
>
|
|
||||||
🗑️ Löschen
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : mode !== 'single-image' ? (
|
) : mode !== 'single-image' ? (
|
||||||
// Public view mode (only for group cards, not single images)
|
// Public view mode (only for group cards, not single images)
|
||||||
<button
|
<button className="view-button" onClick={() => onViewImages(item)} title="Anzeigen">Anzeigen</button>
|
||||||
className="view-button"
|
|
||||||
onClick={() => onViewImages(item)}
|
|
||||||
title="Anzeigen"
|
|
||||||
>
|
|
||||||
Anzeigen
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ const Loading = () => {
|
||||||
<div className="loading-logo-container">
|
<div className="loading-logo-container">
|
||||||
<div className="rotor">
|
<div className="rotor">
|
||||||
<svg
|
<svg
|
||||||
className="loading-logo"
|
class="loading-logo"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
viewBox="0 0 841.89 595.28"
|
viewBox="260 90 310 190"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<g id="g136" display="inline">
|
<g id="g136" display="inline">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ function DescriptionInput({
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
const fieldLabelSx = {
|
const fieldLabelSx = {
|
||||||
fontFamily: 'roboto',
|
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#555555',
|
color: '#555555',
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
|
|
@ -25,7 +24,6 @@ function DescriptionInput({
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionTitleSx = {
|
const sectionTitleSx = {
|
||||||
fontFamily: 'roboto',
|
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
marginBottom: '15px',
|
marginBottom: '15px',
|
||||||
|
|
@ -68,7 +66,7 @@ function DescriptionInput({
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredIndicatorSx = { color: '#E57373', fontSize: '16px' };
|
const requiredIndicatorSx = { color: '#E57373', fontSize: '16px' };
|
||||||
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px', fontStyle: 'italic' };
|
const optionalIndicatorSx = { color: '#9E9E9E', fontSize: '12px' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}>
|
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}>
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,13 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
||||||
|
|
||||||
const dropzoneTextSx = {
|
const dropzoneTextSx = {
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontFamily: 'roboto',
|
|
||||||
color: '#666666',
|
color: '#666666',
|
||||||
margin: '10px 0'
|
margin: '10px 0'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropzoneSubtextSx = {
|
const dropzoneSubtextSx = {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#999999',
|
color: '#999999'
|
||||||
fontFamily: 'roboto'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileCountSx = {
|
const fileCountSx = {
|
||||||
|
|
@ -106,7 +104,7 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Typography sx={dropzoneTextSx}>
|
<Typography sx={dropzoneTextSx}>
|
||||||
📸 Mehrere Bilder hier hinziehen oder klicken zum Auswählen
|
Mehrere Bilder hier hinziehen oder klicken zum Auswählen
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography sx={dropzoneSubtextSx}>
|
<Typography sx={dropzoneSubtextSx}>
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,29 @@ function UploadSuccessDialog({ open, onClose, groupId, uploadCount }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopyGroupId = () => {
|
const handleCopyGroupId = () => {
|
||||||
navigator.clipboard.writeText(groupId).then(() => {
|
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
|
||||||
setCopied(true);
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
setTimeout(() => setCopied(false), 2000);
|
navigator.clipboard.writeText(groupId).then(() => {
|
||||||
}).catch(err => {
|
setCopied(true);
|
||||||
console.error('Failed to copy:', err);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
});
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: Erstelle temporäres Input-Element
|
||||||
|
try {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = groupId;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
.stats-display-container {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-title {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-display {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
color: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './StatsDisplay.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatsDisplay Component
|
||||||
|
* Displays statistics in a grid layout
|
||||||
|
*
|
||||||
|
* @param {Array} stats - Array of stat objects { number, label }
|
||||||
|
*/
|
||||||
|
const StatsDisplay = ({ stats }) => {
|
||||||
|
return (
|
||||||
|
<div className="stats-display-container">
|
||||||
|
<h2 className="stats-title">Statistiken</h2>
|
||||||
|
<div className="stats-display">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="stat-item">
|
||||||
|
<span className="stat-number">{stat.number}</span>
|
||||||
|
<span className="stat-label">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsDisplay;
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,68 +0,0 @@
|
||||||
.container404{
|
|
||||||
margin-top: 25vh;
|
|
||||||
display: -ms-flexbox;
|
|
||||||
display: -webkit-flex;
|
|
||||||
display: flex;
|
|
||||||
-webkit-flex-direction: row;
|
|
||||||
-ms-flex-direction: row;
|
|
||||||
flex-direction: row;
|
|
||||||
-webkit-flex-wrap: nowrap;
|
|
||||||
-ms-flex-wrap: nowrap;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
-webkit-justify-content: center;
|
|
||||||
-ms-flex-pack: center;
|
|
||||||
justify-content: center;
|
|
||||||
-webkit-align-content: center;
|
|
||||||
-ms-flex-line-pack: center;
|
|
||||||
align-content: center;
|
|
||||||
-webkit-align-items: center;
|
|
||||||
-ms-flex-align: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page404 {
|
|
||||||
width: 400px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tree{
|
|
||||||
stroke: #59513C;
|
|
||||||
}
|
|
||||||
|
|
||||||
#wood-stump{
|
|
||||||
stroke: #59513C;
|
|
||||||
-webkit-animation: wood-stump 3s infinite ease-in-out;
|
|
||||||
-moz-animation: wood-stump 3s infinite ease-in-out;
|
|
||||||
-o-animation: wood-stump 3s infinite ease-in-out;
|
|
||||||
animation: wood-stump 3s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@-webkit-keyframes wood-stump{ 0% { -webkit-transform: translate(100px) } 50% { -webkit-transform: translate(50px); } 100% { -webkit-transform: translate(100px); } }
|
|
||||||
@-moz-keyframes wood-stump{ 0% { -moz-transform: translate(100px); } 50% { -moz-transform: translate(50px); } 100% { -moz-transform: translate(100px); } }
|
|
||||||
@-o-keyframes wood-stump{ 0% { -o-transform: translate(100px); } 50% { -o-transform: translate(50px); } 100% { -o-transform: translate(100px); } }
|
|
||||||
@keyframes wood-stump{ 0% {-webkit-transform: translate(100px);-moz-transform: translate(100px);-ms-transform: translate(100px);transform: translate(100px); } 50% {-webkit-transform: translate(0px);-moz-transform: translate(0px);-ms-transform: translate(0px);transform: translate(0px); } 100% {-webkit-transform: translate(100px); -moz-transform: translate(100px);-ms-transform: translate(100px);transform: translate(100px); } }
|
|
||||||
|
|
||||||
|
|
||||||
#leaf{
|
|
||||||
stroke: #59513C;
|
|
||||||
-webkit-animation: leaf 7s infinite ease-in-out;
|
|
||||||
-moz-animation: leaf 7s infinite ease-in-out;
|
|
||||||
-o-animation: leaf 7s infinite ease-in-out;
|
|
||||||
animation: leaf 7s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@-webkit-keyframes leaf{ 0% { -webkit-transform: translate(0, 70px) } 50% { -webkit-transform: translate(0, 50px); } 100% { -webkit-transform: translate(0, 70px); } }
|
|
||||||
@-moz-keyframes leaf{ 0% { -moz-transform: translate(0, 70px); } 50% { -moz-transform: translate(0, 50px); } 100% { -moz-transform: translate(0, 70px); } }
|
|
||||||
@-o-keyframes leaf{ 0% { -o-transform: translate(0, 70px); } 50% { -o-transform: translate(0, 50px); } 100% { -o-transform: translate(0, 70px); } }
|
|
||||||
@keyframes leaf{ 0% {-webkit-transform: translate(0, 70px);-moz-transform: translate(0, 70px);-ms-transform: translate(0, 70px);transform: translate(0, 70px); } 50% {-webkit-transform: translate(0px);-moz-transform: translate(0px);-ms-transform: translate(0px);transform: translate(0px); } 100% {-webkit-transform: translate(0, 70px); -moz-transform: translate(0, 70px);-ms-transform: translate(0, 70px);transform: translate(0, 70px); } }
|
|
||||||
|
|
||||||
#border{
|
|
||||||
stroke: #59513C;
|
|
||||||
}
|
|
||||||
|
|
||||||
#Page{
|
|
||||||
fill: #59513C;
|
|
||||||
}
|
|
||||||
#notFound{
|
|
||||||
fill: #A7444B;
|
|
||||||
}
|
|
||||||
9
frontend/src/Components/Pages/Css/ErrorPage.css
Normal file
9
frontend/src/Components/Pages/Css/ErrorPage.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/* Error Pages Container */
|
||||||
|
.containerError {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
179
frontend/src/Components/Pages/Css/ModerationGroupsPage.css
Normal file
179
frontend/src/Components/Pages/Css/ModerationGroupsPage.css
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/* Moderation Page Layout */
|
||||||
|
.moderation-content {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-username {
|
||||||
|
color: #666666;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Controls Area */
|
||||||
|
.moderation-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.moderation-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and Error States */
|
||||||
|
.moderation-loading,
|
||||||
|
.moderation-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-error {
|
||||||
|
color: #d32f2f;
|
||||||
|
background-color: #ffebee;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Modal */
|
||||||
|
.image-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-details {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-details p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-actions {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.moderation-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/Components/Pages/ErrorPage.js
Normal file
56
frontend/src/Components/Pages/ErrorPage.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Navbar from '../ComponentUtils/Headers/Navbar'
|
||||||
|
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload'
|
||||||
|
import ErrorAnimation from '../ComponentUtils/ErrorAnimation/ErrorAnimation'
|
||||||
|
import { getHostConfig } from '../../Utils/hostDetection'
|
||||||
|
|
||||||
|
import './Css/ErrorPage.css'
|
||||||
|
import '../../App.css'
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
'403': {
|
||||||
|
title: '403 - Zugriff verweigert',
|
||||||
|
description: 'Sie haben keine Berechtigung, auf diese Ressource zuzugreifen.'
|
||||||
|
},
|
||||||
|
'404': {
|
||||||
|
title: '404 - Seite nicht gefunden',
|
||||||
|
description: 'Die angeforderte Seite existiert nicht.'
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
title: '500 - Interner Serverfehler',
|
||||||
|
description: 'Es ist ein interner Serverfehler aufgetreten.'
|
||||||
|
},
|
||||||
|
'502': {
|
||||||
|
title: '502 - Bad Gateway',
|
||||||
|
description: 'Der Server hat eine ungültige Antwort erhalten.'
|
||||||
|
},
|
||||||
|
'503': {
|
||||||
|
title: '503 - Service nicht verfügbar',
|
||||||
|
description: 'Der Service ist vorübergehend nicht verfügbar.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function ErrorPage({ errorCode = '404' }) {
|
||||||
|
const hostConfig = getHostConfig();
|
||||||
|
const error = ERROR_MESSAGES[errorCode] || ERROR_MESSAGES['404'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="allContainerNoBackground">
|
||||||
|
{hostConfig.isPublic ? <NavbarUpload /> : <Navbar />}
|
||||||
|
|
||||||
|
<div className="containerError">
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h1 style={{ textAlign: 'center' }}>{error.title}</h1>
|
||||||
|
<p>{error.description}</p>
|
||||||
|
<ErrorAnimation errorCode={errorCode} />
|
||||||
|
<a href="/" style={{ color: '#007bff', textDecoration: 'underline' }}>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ErrorPage
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Card,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
CircularProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,14 +56,14 @@ function GroupsOverviewPage() {
|
||||||
return (
|
return (
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Container maxWidth="lg" className="page-container">
|
<div className="container">
|
||||||
<div className="loading-container">
|
<div className="flex-center" style={{ minHeight: '400px' }}>
|
||||||
<CircularProgress size={60} color="primary" />
|
<div className="text-center">
|
||||||
<Typography variant="h6" style={{ marginTop: '20px', color: '#666666' }}>
|
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid #f3f3f3', borderTop: '4px solid #3498db', borderRadius: '50%', animation: 'spin 1s linear infinite', margin: '0 auto' }}></div>
|
||||||
Slideshows werden geladen...
|
<p className="mt-3" style={{ color: '#666666' }}>Slideshows werden geladen...</p>
|
||||||
</Typography>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -86,53 +79,39 @@ function GroupsOverviewPage() {
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<Container maxWidth="lg" className="page-container">
|
<div className="container page-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Card className="header-card">
|
<div className="card">
|
||||||
<Typography className="header-title">
|
<h1 className="page-title">Alle Slideshows</h1>
|
||||||
Alle Slideshows
|
<p className="page-subtitle">Übersicht aller erstellten Slideshows.</p>
|
||||||
</Typography>
|
</div>
|
||||||
<Typography className="header-subtitle">
|
|
||||||
Übersicht aller erstellten Slideshows.
|
|
||||||
</Typography>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Groups Grid */}
|
{/* Groups Grid */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Typography variant="h6" style={{ color: '#f44336', marginBottom: '20px' }}>
|
<h2 style={{ color: '#f44336' }} className="mb-3">😕 Fehler beim Laden</h2>
|
||||||
😕 Fehler beim Laden
|
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
<button onClick={loadGroups} className="btn btn-secondary">
|
<button onClick={loadGroups} className="btn btn-secondary">
|
||||||
🔄 Erneut versuchen
|
🔄 Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Typography variant="h4" style={{ color: '#666666', marginBottom: '20px' }}>
|
<h2 style={{ color: '#666666' }} className="mb-3">📸 Keine Slideshows vorhanden</h2>
|
||||||
📸 Keine Slideshows vorhanden
|
<p style={{ color: '#999999' }} className="mb-4">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
|
|
||||||
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
|
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
|
||||||
</Typography>
|
</p>
|
||||||
<button
|
<button className="btn btn-success" onClick={handleCreateNew}>
|
||||||
className="btn btn-success"
|
|
||||||
onClick={handleCreateNew}
|
|
||||||
style={{ fontSize: '16px', padding: '12px 24px' }}
|
|
||||||
>
|
|
||||||
➕ Erste Slideshow erstellen
|
➕ Erste Slideshow erstellen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box marginBottom={2}>
|
<div className="mb-3">
|
||||||
<Typography variant="h6" style={{ color: '#666666' }}>
|
<h3 style={{ color: '#666666' }}>
|
||||||
📊 {groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
{groups.length} Slideshow{groups.length !== 1 ? 's' : ''} gefunden
|
||||||
</Typography>
|
</h3>
|
||||||
</Box>
|
</div>
|
||||||
<ImageGallery
|
<ImageGallery
|
||||||
items={groups}
|
items={groups}
|
||||||
onViewImages={(group) => handleViewGroup(group.groupId)}
|
onViewImages={(group) => handleViewGroup(group.groupId)}
|
||||||
|
|
@ -142,7 +121,7 @@ function GroupsOverviewPage() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<div className="footerContainer">
|
<div className="footerContainer">
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Container, Card, CardContent, Typography, Box, Button } from '@mui/material';
|
|
||||||
import Swal from 'sweetalert2';
|
import Swal from 'sweetalert2';
|
||||||
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
|
|
@ -12,6 +11,7 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
|
||||||
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
||||||
import ConsentManager from '../ComponentUtils/ConsentManager';
|
import ConsentManager from '../ComponentUtils/ConsentManager';
|
||||||
import DeleteGroupButton from '../ComponentUtils/DeleteGroupButton';
|
import DeleteGroupButton from '../ComponentUtils/DeleteGroupButton';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ManagementPortalPage - Self-service management for uploaded groups
|
* ManagementPortalPage - Self-service management for uploaded groups
|
||||||
|
|
@ -36,7 +36,7 @@ function ManagementPortalPage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(`/api/manage/${token}`);
|
const res = await apiFetch(`/api/manage/${token}`);
|
||||||
|
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
setError('Ungültiger oder abgelaufener Verwaltungslink');
|
setError('Ungültiger oder abgelaufener Verwaltungslink');
|
||||||
|
|
@ -105,7 +105,7 @@ function ManagementPortalPage() {
|
||||||
formData.append('images', file);
|
formData.append('images', file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`/api/manage/${token}/images`, {
|
const res = await apiFetch(`/api/manage/${token}/images`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
@ -146,7 +146,7 @@ function ManagementPortalPage() {
|
||||||
const imageIds = newOrder.map(img => img.id);
|
const imageIds = newOrder.map(img => img.id);
|
||||||
|
|
||||||
// Use token-based management API
|
// Use token-based management API
|
||||||
const response = await fetch(`/api/manage/${token}/reorder`, {
|
const response = await apiFetch(`/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 })
|
||||||
|
|
@ -179,9 +179,9 @@ function ManagementPortalPage() {
|
||||||
return (
|
return (
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<NavbarUpload />
|
<NavbarUpload />
|
||||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
<div className="container flex-center" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||||
<Loading />
|
<Loading />
|
||||||
</Container>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -191,19 +191,15 @@ function ManagementPortalPage() {
|
||||||
return (
|
return (
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<NavbarUpload />
|
<NavbarUpload />
|
||||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', textAlign: 'center' }}>
|
<div className="card text-center">
|
||||||
<Typography variant="h5" color="error" gutterBottom>
|
<h2 style={{ color: '#f44336' }} className="mb-2">{error}</h2>
|
||||||
{error}
|
<p style={{ color: '#666666' }} className="mb-4">{error}</p>
|
||||||
</Typography>
|
<button className="btn btn-primary" onClick={() => navigate('/')}>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
<Button variant="contained" onClick={() => navigate('/')}>
|
|
||||||
Zur Startseite
|
Zur Startseite
|
||||||
</Button>
|
</button>
|
||||||
</Card>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -213,19 +209,17 @@ function ManagementPortalPage() {
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<NavbarUpload />
|
<NavbarUpload />
|
||||||
|
|
||||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
|
<div className="card mb-3">
|
||||||
<CardContent>
|
<div className="card-content">
|
||||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
|
<h1 className="page-title text-center mb-2">Mein Upload verwalten</h1>
|
||||||
Mein Upload verwalten
|
<p className="page-subtitle text-center mb-4">
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
|
|
||||||
Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern.
|
Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
{/* Group Overview */}
|
{/* Group Overview */}
|
||||||
{group && (
|
{group && (
|
||||||
<Box sx={{ mb: 3 }}>
|
<div className="mb-4">
|
||||||
<ImageGalleryCard
|
<ImageGalleryCard
|
||||||
item={group}
|
item={group}
|
||||||
showActions={false}
|
showActions={false}
|
||||||
|
|
@ -234,29 +228,25 @@ function ManagementPortalPage() {
|
||||||
hidePreview={true}
|
hidePreview={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<div className="mt-3">
|
||||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
<h3 className="text-small" style={{ fontWeight: 600 }}>Erteilte Einwilligungen:</h3>
|
||||||
Erteilte Einwilligungen:
|
|
||||||
</Typography>
|
|
||||||
<ConsentBadges group={group} />
|
<ConsentBadges group={group} />
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Images Dropzone */}
|
{/* Add Images Dropzone */}
|
||||||
<Box sx={{ mb: 3 }}>
|
<div className="mb-4">
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
|
<h3 className="mb-2" style={{ fontWeight: 600 }}>Weitere Bilder hinzufügen</h3>
|
||||||
Weitere Bilder hinzufügen
|
|
||||||
</Typography>
|
|
||||||
<MultiImageDropzone
|
<MultiImageDropzone
|
||||||
onImagesSelected={handleImagesSelected}
|
onImagesSelected={handleImagesSelected}
|
||||||
selectedImages={[]}
|
selectedImages={[]}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* Image Descriptions Manager */}
|
{/* Image Descriptions Manager */}
|
||||||
{group && group.images && group.images.length > 0 && (
|
{group && group.images && group.images.length > 0 && (
|
||||||
<Box sx={{ mb: 3 }}>
|
<div className="mb-4">
|
||||||
<ImageDescriptionManager
|
<ImageDescriptionManager
|
||||||
images={group.images}
|
images={group.images}
|
||||||
token={token}
|
token={token}
|
||||||
|
|
@ -264,44 +254,44 @@ function ManagementPortalPage() {
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
onRefresh={loadGroup}
|
onRefresh={loadGroup}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Group Metadata Editor */}
|
{/* Group Metadata Editor */}
|
||||||
{group && (
|
{group && (
|
||||||
<Box sx={{ mb: 3 }}>
|
<div className="mb-4">
|
||||||
<GroupMetadataEditor
|
<GroupMetadataEditor
|
||||||
initialMetadata={group.metadata}
|
initialMetadata={group.metadata}
|
||||||
token={token}
|
token={token}
|
||||||
onRefresh={loadGroup}
|
onRefresh={loadGroup}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Consent Manager */}
|
{/* Consent Manager */}
|
||||||
{group && (
|
{group && (
|
||||||
<Box sx={{ mb: 3 }}>
|
<div className="mb-4">
|
||||||
<ConsentManager
|
<ConsentManager
|
||||||
initialConsents={group.consents}
|
initialConsents={group.consents}
|
||||||
token={token}
|
token={token}
|
||||||
groupId={group.groupId}
|
groupId={group.groupId}
|
||||||
onRefresh={loadGroup}
|
onRefresh={loadGroup}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Group Button */}
|
{/* Delete Group Button */}
|
||||||
{group && (
|
{group && (
|
||||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
<div className="mt-4 flex-center">
|
||||||
<DeleteGroupButton
|
<DeleteGroupButton
|
||||||
token={token}
|
token={token}
|
||||||
groupName={group.title || group.name || 'diese Gruppe'}
|
groupName={group.title || group.name || 'diese Gruppe'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<div className="footerContainer">
|
<div className="footerContainer">
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Container, Box } from '@mui/material';
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { adminGet } from '../../services/adminApi';
|
import { adminGet, adminRequest } from '../../services/adminApi';
|
||||||
import { handleAdminError } from '../../services/adminErrorHandler';
|
import { handleAdminError } from '../../services/adminErrorHandler';
|
||||||
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
|
import AdminSessionGate from '../AdminAuth/AdminSessionGate.jsx';
|
||||||
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
import { useAdminSession } from '../../contexts/AdminSessionContext.jsx';
|
||||||
|
|
@ -15,6 +14,9 @@ import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager';
|
||||||
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor';
|
||||||
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
|
||||||
|
|
||||||
|
// UI
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModerationGroupImagesPage - Admin page for moderating group images
|
* ModerationGroupImagesPage - Admin page for moderating group images
|
||||||
*
|
*
|
||||||
|
|
@ -72,6 +74,35 @@ const ModerationGroupImagesPage = () => {
|
||||||
loadGroup();
|
loadGroup();
|
||||||
}, [isAuthenticated, loadGroup]);
|
}, [isAuthenticated, loadGroup]);
|
||||||
|
|
||||||
|
const handleReorder = async (newOrder) => {
|
||||||
|
if (!group || !groupId) {
|
||||||
|
console.error('No groupId available for reordering');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageIds = newOrder.map(img => img.id);
|
||||||
|
|
||||||
|
// Use admin API
|
||||||
|
await adminRequest(`/api/admin/groups/${groupId}/reorder`, 'PUT', {
|
||||||
|
imageIds: imageIds
|
||||||
|
});
|
||||||
|
|
||||||
|
await Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Gespeichert',
|
||||||
|
text: 'Die neue Reihenfolge wurde gespeichert.',
|
||||||
|
timer: 1500,
|
||||||
|
showConfirmButton: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadGroup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reordering images:', error);
|
||||||
|
await handleAdminError(error, 'Reihenfolge speichern');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (loading) return <Loading />;
|
if (loading) return <Loading />;
|
||||||
if (error) return <div className="moderation-error">{error}</div>;
|
if (error) return <div className="moderation-error">{error}</div>;
|
||||||
|
|
@ -81,13 +112,15 @@ const ModerationGroupImagesPage = () => {
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
<div className="container" style={{ minHeight: '80vh', paddingTop: '20px', paddingBottom: '40px' }}>
|
||||||
{/* Image Descriptions Manager */}
|
{/* Image Descriptions Manager */}
|
||||||
<ImageDescriptionManager
|
<ImageDescriptionManager
|
||||||
images={group.images}
|
images={group.images}
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
onRefresh={loadGroup}
|
onRefresh={loadGroup}
|
||||||
mode="moderate"
|
mode="moderate"
|
||||||
|
enableReordering={true}
|
||||||
|
onReorder={handleReorder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group Metadata Editor */}
|
{/* Group Metadata Editor */}
|
||||||
|
|
@ -99,15 +132,15 @@ const ModerationGroupImagesPage = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
<div className="flex-center mt-4">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => navigate('/moderation')}
|
onClick={() => navigate('/moderation')}
|
||||||
>
|
>
|
||||||
↩ Zurück zur Übersicht
|
↩ Zurück zur Übersicht
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<div className="footerContainer"><Footer /></div>
|
<div className="footerContainer"><Footer /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Container, Box, FormControl, FormLabel, FormGroup, FormControlLabel, Checkbox, Typography } from '@mui/material';
|
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
|
||||||
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
import Swal from 'sweetalert2/dist/sweetalert2.js';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|
@ -17,8 +15,14 @@ import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import ImageGallery from '../ComponentUtils/ImageGallery';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
import DeletionLogSection from '../ComponentUtils/DeletionLogSection';
|
import DeletionLogSection from '../ComponentUtils/DeletionLogSection';
|
||||||
|
import ConsentFilter from '../ComponentUtils/ConsentFilter/ConsentFilter';
|
||||||
|
import StatsDisplay from '../ComponentUtils/StatsDisplay/StatsDisplay';
|
||||||
import { getImageSrc } from '../../Utils/imageUtils';
|
import { getImageSrc } from '../../Utils/imageUtils';
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import './Css/ModerationGroupsPage.css';
|
||||||
|
import '../../App.css';
|
||||||
|
|
||||||
const ModerationGroupsPage = () => {
|
const ModerationGroupsPage = () => {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -268,24 +272,17 @@ const ModerationGroupsPage = () => {
|
||||||
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
|
<meta name="description" content="Interne Moderationsseite - Nicht öffentlich zugänglich" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<Container className="moderation-content" maxWidth="lg" style={{ paddingTop: '20px' }}>
|
<div className="container moderation-content">
|
||||||
<Box sx={{
|
<div className="moderation-header">
|
||||||
display: 'flex',
|
<h1>Moderation</h1>
|
||||||
alignItems: 'center',
|
<div className="moderation-user-info">
|
||||||
justifyContent: 'space-between',
|
<button className="btn btn-success" onClick={exportConsentData}> Consent-Daten exportieren </button>
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 2,
|
|
||||||
mb: 3
|
|
||||||
}}>
|
|
||||||
<Typography variant="h4" component="h1">
|
|
||||||
Moderation
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
{user?.username && (
|
{user?.username && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="moderation-username">
|
||||||
Eingeloggt als <strong>{user.username}</strong>
|
Eingeloggt als <strong>{user.username}</strong>
|
||||||
</Typography>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-outline-secondary"
|
||||||
|
|
@ -295,98 +292,36 @@ const ModerationGroupsPage = () => {
|
||||||
>
|
>
|
||||||
{logoutPending ? 'Wird abgemeldet…' : 'Logout'}
|
{logoutPending ? 'Wird abgemeldet…' : 'Logout'}
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<div className="moderation-stats">
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number">{pendingGroups.length}</span>
|
|
||||||
<span className="stat-label">Wartend</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number">{approvedGroups.length}</span>
|
|
||||||
<span className="stat-label">Freigegeben</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-item">
|
|
||||||
<span className="stat-number">{groups.length}</span>
|
|
||||||
<span className="stat-label">Gesamt</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Lösch-Historie */}
|
||||||
|
<section className="moderation-section">
|
||||||
|
<DeletionLogSection />
|
||||||
|
</section>
|
||||||
|
<StatsDisplay
|
||||||
|
stats={[
|
||||||
|
{ number: pendingGroups.length, label: 'Wartend' },
|
||||||
|
{ number: approvedGroups.length, label: 'Freigegeben' },
|
||||||
|
{ number: groups.length, label: 'Gesamt' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter und Export Controls */}
|
{/* Filter und Export Controls */}
|
||||||
<Box sx={{
|
<ConsentFilter
|
||||||
display: 'flex',
|
filters={consentFilters}
|
||||||
gap: 2,
|
onChange={setConsentFilters}
|
||||||
mb: 3,
|
platforms={platforms}
|
||||||
alignItems: 'center',
|
/>
|
||||||
flexWrap: 'wrap'
|
|
||||||
}}>
|
|
||||||
<FormControl component="fieldset" sx={{ minWidth: 250 }}>
|
|
||||||
<FormLabel component="legend" sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
|
||||||
<FilterListIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
|
||||||
Consent-Filter
|
|
||||||
</FormLabel>
|
|
||||||
<FormGroup>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={consentFilters.workshop}
|
|
||||||
onChange={(e) => setConsentFilters({...consentFilters, workshop: e.target.checked})}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Werkstatt"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={consentFilters.facebook}
|
|
||||||
onChange={(e) => setConsentFilters({...consentFilters, facebook: e.target.checked})}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Facebook"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={consentFilters.instagram}
|
|
||||||
onChange={(e) => setConsentFilters({...consentFilters, instagram: e.target.checked})}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Instagram"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={consentFilters.tiktok}
|
|
||||||
onChange={(e) => setConsentFilters({...consentFilters, tiktok: e.target.checked})}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="TikTok"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-success"
|
|
||||||
onClick={exportConsentData}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
padding: '10px 20px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📥 Consent-Daten exportieren
|
|
||||||
</button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Wartende Gruppen */}
|
{/* Wartende Gruppen */}
|
||||||
<section className="moderation-section">
|
<section className="moderation-section">
|
||||||
<ImageGallery
|
<ImageGallery
|
||||||
items={pendingGroups}
|
items={pendingGroups}
|
||||||
title={`🔍 Wartende Freigabe (${pendingGroups.length})`}
|
title={`Wartende Freigabe (${pendingGroups.length})`}
|
||||||
onApprove={approveGroup}
|
onApprove={approveGroup}
|
||||||
onViewImages={viewGroupImages}
|
onViewImages={viewGroupImages}
|
||||||
onDelete={deleteGroup}
|
onDelete={deleteGroup}
|
||||||
|
|
@ -400,7 +335,7 @@ const ModerationGroupsPage = () => {
|
||||||
<section className="moderation-section">
|
<section className="moderation-section">
|
||||||
<ImageGallery
|
<ImageGallery
|
||||||
items={approvedGroups}
|
items={approvedGroups}
|
||||||
title={`✅ Freigegebene Gruppen (${approvedGroups.length})`}
|
title={`Freigegebene Gruppen (${approvedGroups.length})`}
|
||||||
onApprove={approveGroup}
|
onApprove={approveGroup}
|
||||||
onViewImages={viewGroupImages}
|
onViewImages={viewGroupImages}
|
||||||
onDelete={deleteGroup}
|
onDelete={deleteGroup}
|
||||||
|
|
@ -410,10 +345,7 @@ const ModerationGroupsPage = () => {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Lösch-Historie */}
|
|
||||||
<section className="moderation-section">
|
|
||||||
<DeletionLogSection />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Bilder-Modal */}
|
{/* Bilder-Modal */}
|
||||||
{showImages && selectedGroup && (
|
{showImages && selectedGroup && (
|
||||||
|
|
@ -426,7 +358,7 @@ const ModerationGroupsPage = () => {
|
||||||
onDeleteImage={deleteImage}
|
onDeleteImage={deleteImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</div>
|
||||||
<div className="footerContainer"><Footer /></div>
|
<div className="footerContainer"><Footer /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -471,7 +403,7 @@ const ImageModal = ({ group, onClose, onDeleteImage }) => {
|
||||||
<div className="image-actions">
|
<div className="image-actions">
|
||||||
<span className="image-name">{image.originalName}</span>
|
<span className="image-name">{image.originalName}</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger btn-sm"
|
className="btn btn-danger"
|
||||||
onClick={() => onDeleteImage(group.groupId, image.id)}
|
onClick={() => onDeleteImage(group.groupId, image.id)}
|
||||||
title="Bild löschen"
|
title="Bild löschen"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, Typography, Container, Box } from '@mui/material';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
|
||||||
|
|
@ -163,17 +162,17 @@ function MultiUploadPage() {
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
{<NavbarUpload />}
|
{<NavbarUpload />}
|
||||||
|
|
||||||
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
|
<div className="container">
|
||||||
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
|
<div className="card">
|
||||||
<CardContent>
|
<div className="card-content">
|
||||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
|
<h1 className="page-title">
|
||||||
Project Image Uploader
|
Project Image Uploader
|
||||||
</Typography>
|
</h1>
|
||||||
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
|
<p className="page-subtitle">
|
||||||
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe dein Projekt in wenigen Worten.
|
Lade ein oder mehrere Bilder von deinem Projekt hoch und beschreibe dein Projekt in wenigen Worten.
|
||||||
<br />
|
<br />
|
||||||
Die Bilder werden nur hier im Hobbyhimmel auf dem Monitor gezeigt, es wird an keine Dritten weiter gegeben.
|
Die Bilder werden nur in der Hobbyhimmel Werkstatt auf den Monitoren gezeigt, es wird an keine Dritten weiter gegeben, sofern du deine Einwilligung nicht erteilst.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
{!uploading ? (
|
{!uploading ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -215,15 +214,11 @@ function MultiUploadPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', mt: 3, flexWrap: 'wrap' }}>
|
<div className="flex-center">
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!canUpload()}
|
disabled={!canUpload()}
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
padding: '12px 30px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
|
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -231,14 +226,10 @@ function MultiUploadPage() {
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
padding: '12px 30px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
🗑️ Alle entfernen
|
🗑️ Alle entfernen
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -254,129 +245,109 @@ function MultiUploadPage() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{
|
<div className="success-box">
|
||||||
mt: 4,
|
<h2>
|
||||||
p: 3,
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
|
|
||||||
color: 'white',
|
|
||||||
boxShadow: '0 4px 20px rgba(76, 175, 80, 0.4)',
|
|
||||||
animation: 'slideIn 0.5s ease-out',
|
|
||||||
'@keyframes slideIn': {
|
|
||||||
from: {
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateY(-20px)'
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateY(0)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Typography sx={{ fontSize: '28px', fontWeight: 'bold', mb: 1 }}>
|
|
||||||
✅ Upload erfolgreich!
|
✅ Upload erfolgreich!
|
||||||
</Typography>
|
</h2>
|
||||||
<Typography sx={{ fontSize: '18px', mb: 2 }}>
|
<p>
|
||||||
{uploadResult?.imageCount || 0} Bild{uploadResult?.imageCount === 1 ? '' : 'er'} {uploadResult?.imageCount === 1 ? 'wurde' : 'wurden'} hochgeladen.
|
{uploadResult?.imageCount || 0} Bild{uploadResult?.imageCount === 1 ? '' : 'er'} {uploadResult?.imageCount === 1 ? 'wurde' : 'wurden'} hochgeladen.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Box sx={{ bgcolor: 'rgba(255,255,255,0.2)', borderRadius: '8px', p: 2, mb: 2 }}>
|
<div className="info-box">
|
||||||
<Typography sx={{ fontSize: '14px', mb: 1 }}>
|
<p className="text-small">
|
||||||
Ihre Referenz-Nummer:
|
Ihre Referenz-Nummer:
|
||||||
</Typography>
|
</p>
|
||||||
<Typography sx={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', mb: 1 }}>
|
<p style={{ fontSize: '20px', fontFamily: 'monospace', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
{uploadResult?.groupId}
|
{uploadResult?.groupId}
|
||||||
</Typography>
|
</p>
|
||||||
<Typography sx={{ fontSize: '12px', opacity: 0.9 }}>
|
<p className="text-small" style={{ opacity: 0.9 }}>
|
||||||
Notieren Sie sich diese Nummer für spätere Anfragen an das Team.
|
Notieren Sie sich diese Nummer für spätere Anfragen an das Team.
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{uploadResult?.managementToken && (
|
{uploadResult?.managementToken && (
|
||||||
<Box sx={{
|
<div className="info-box-highlight">
|
||||||
bgcolor: 'rgba(255,255,255,0.95)',
|
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#2e7d32' }}>
|
||||||
borderRadius: '8px',
|
|
||||||
p: 2.5,
|
|
||||||
mb: 2,
|
|
||||||
border: '2px solid rgba(255,255,255,0.3)'
|
|
||||||
}}>
|
|
||||||
<Typography sx={{ fontSize: '16px', fontWeight: 'bold', mb: 1.5, color: '#2e7d32' }}>
|
|
||||||
🔗 Verwaltungslink für Ihren Upload
|
🔗 Verwaltungslink für Ihren Upload
|
||||||
</Typography>
|
</h3>
|
||||||
<Typography sx={{ fontSize: '13px', mb: 1.5, color: '#333' }}>
|
<p style={{ fontSize: '13px', marginBottom: '12px', color: '#333' }}>
|
||||||
Mit diesem Link können Sie später Ihre Bilder verwalten, Einwilligungen widerrufen oder die Gruppe löschen:
|
Mit diesem Link können Sie später Ihre Bilder verwalten, Einwilligungen widerrufen oder die Gruppe löschen:
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Box sx={{
|
<div style={{
|
||||||
bgcolor: '#f5f5f5',
|
background: '#f5f5f5',
|
||||||
p: 1.5,
|
padding: '12px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
mb: 1.5,
|
marginBottom: '12px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: '8px',
|
||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}}>
|
}}>
|
||||||
<Typography sx={{
|
<p style={{
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: '#1976d2',
|
color: '#1976d2',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: '200px'
|
minWidth: '200px',
|
||||||
|
margin: 0
|
||||||
}}>
|
}}>
|
||||||
{window.location.origin}/manage/{uploadResult.managementToken}
|
{window.location.origin}/manage/{uploadResult.managementToken}
|
||||||
</Typography>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '6px 16px'
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
|
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
|
||||||
navigator.clipboard.writeText(link);
|
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
} else {
|
||||||
|
// Fallback: Erstelle temporäres Input-Element
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = link;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📋 Kopieren
|
📋 Kopieren
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography sx={{ fontSize: '11px', color: '#666', mb: 0.5 }}>
|
<p className="text-small" style={{ color: '#666', marginBottom: '4px' }}>
|
||||||
⚠️ <strong>Wichtig:</strong> Bewahren Sie diesen Link sicher auf! Jeder mit diesem Link kann Ihren Upload verwalten.
|
⚠️ <strong>Wichtig:</strong> Bewahre diesen Link sicher auf! Jeder mit diesem Link kann Deinen Upload verwalten.
|
||||||
</Typography>
|
</p>
|
||||||
<Typography sx={{ fontSize: '11px', color: '#666', fontStyle: 'italic' }}>
|
<p className="text-small" style={{ color: '#666', fontStyle: 'italic' }}>
|
||||||
ℹ️ <strong>Hinweis:</strong> Über diesen Link können Sie nur die Bilder in der Werkstatt verwalten. Bereits auf Social Media Plattformen veröffentlichte Bilder müssen separat dort gelöscht werden.
|
ℹ️ <strong>Hinweis:</strong> Über diesen Link kannst Du nur die Bilder in der Werkstatt verwalten. Bereits auf Social Media Plattformen veröffentlichte Bilder müssen separat dort gelöscht werden.
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography sx={{ fontSize: '13px', mb: 2, opacity: 0.95 }}>
|
<p style={{ fontSize: '13px', marginBottom: '16px', opacity: 0.95 }}>
|
||||||
Die Bilder werden geprüft und nach Freigabe auf dem Werkstatt-Monitor angezeigt.
|
Die Bilder werden geprüft und nach Freigabe auf dem Werkstatt-Monitor angezeigt.
|
||||||
{' '}Bei Social Media Einwilligung werden sie entsprechend veröffentlicht.
|
{' '}Bei Social Media Einwilligung werden sie entsprechend veröffentlicht.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Typography sx={{ fontSize: '12px', mb: 3, opacity: 0.9 }}>
|
<p style={{ fontSize: '12px', marginBottom: '24px', opacity: 0.9 }}>
|
||||||
<strong>Fragen oder Widerruf?</strong> Kontakt: <strong>it@hobbyhimmel.de</strong>
|
<strong>Fragen oder Widerruf?</strong> Kontakt: <strong>it@hobbyhimmel.de</strong>
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
padding: '12px 30px'
|
|
||||||
}}
|
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
👍 Weitere Bilder hochladen
|
👍 Weitere Bilder hochladen
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<div className="footerContainer">
|
<div className="footerContainer">
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Container } from '@mui/material';
|
|
||||||
import Navbar from '../ComponentUtils/Headers/Navbar';
|
import Navbar from '../ComponentUtils/Headers/Navbar';
|
||||||
import Footer from '../ComponentUtils/Footer';
|
import Footer from '../ComponentUtils/Footer';
|
||||||
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
|
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
|
||||||
import ImageGallery from '../ComponentUtils/ImageGallery';
|
import ImageGallery from '../ComponentUtils/ImageGallery';
|
||||||
|
import { apiFetch } from '../../Utils/apiFetch';
|
||||||
|
|
||||||
|
|
||||||
const PublicGroupImagesPage = () => {
|
const PublicGroupImagesPage = () => {
|
||||||
|
|
@ -22,7 +22,7 @@ const PublicGroupImagesPage = () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Public endpoint (no moderation controls)
|
// Public endpoint (no moderation controls)
|
||||||
const res = await fetch(`/api/groups/${groupId}`);
|
const res = await apiFetch(`/api/groups/${groupId}`);
|
||||||
if (!res.ok) throw new Error('Nicht gefunden');
|
if (!res.ok) throw new Error('Nicht gefunden');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setGroup(data);
|
setGroup(data);
|
||||||
|
|
@ -41,7 +41,7 @@ const PublicGroupImagesPage = () => {
|
||||||
<div className="allContainer">
|
<div className="allContainer">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<Container maxWidth="lg" className="page-container" style={{ marginTop: '40px' }}>
|
<div className="container page-container" style={{ marginTop: '40px' }}>
|
||||||
<ImageGalleryCard
|
<ImageGalleryCard
|
||||||
item={group}
|
item={group}
|
||||||
showActions={false}
|
showActions={false}
|
||||||
|
|
@ -69,7 +69,7 @@ const PublicGroupImagesPage = () => {
|
||||||
return acc;
|
return acc;
|
||||||
}, {}) : {}}
|
}, {}) : {}}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
<div className="footerContainer"><Footer /></div>
|
<div className="footerContainer"><Footer /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
import {
|
||||||
Home as HomeIcon,
|
Home as HomeIcon,
|
||||||
ExitToApp as ExitIcon
|
ExitToApp as ExitIcon
|
||||||
|
|
@ -172,12 +166,12 @@ function SlideshowPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={fullscreenSx}>
|
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||||
<Box sx={loadingContainerSx}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||||
<CircularProgress sx={{ color: 'white', mb: 2 }} />
|
<div className="loading-spinner" style={{ width: '60px', height: '60px', border: '4px solid rgba(255,255,255,0.3)', borderTop: '4px solid white', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '16px' }}></div>
|
||||||
<Typography sx={{ color: 'white' }}>Slideshow wird geladen...</Typography>
|
<p style={{ color: 'white', margin: 0 }}>Slideshow wird geladen...</p>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,27 +186,27 @@ function SlideshowPage() {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box sx={fullscreenSx}>
|
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||||
<Box sx={loadingContainerSx}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||||
<Typography sx={{ color: 'white', fontSize: '24px' }}>{error}</Typography>
|
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>{error}</p>
|
||||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</IconButton>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentGroup || !currentImage) {
|
if (!currentGroup || !currentImage) {
|
||||||
return (
|
return (
|
||||||
<Box sx={fullscreenSx}>
|
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||||
<Box sx={loadingContainerSx}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: 'white' }}>
|
||||||
<Typography sx={{ color: 'white', fontSize: '24px' }}>Keine Bilder verfügbar</Typography>
|
<p style={{ color: 'white', fontSize: '24px', margin: 0 }}>Keine Bilder verfügbar</p>
|
||||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite">
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</IconButton>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,41 +269,41 @@ function SlideshowPage() {
|
||||||
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
|
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={fullscreenSx}>
|
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: '#000', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', zIndex: 9999, overflow: 'hidden' }}>
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
|
<button style={{ position: 'absolute', top: '20px', left: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Zur Startseite" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</IconButton>
|
</button>
|
||||||
|
|
||||||
<IconButton sx={exitButtonSx} onClick={() => navigate('/')} title="Slideshow beenden">
|
<button style={{ position: 'absolute', top: '20px', right: '20px', color: 'white', backgroundColor: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={() => navigate('/')} title="Slideshow beenden" onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'} onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.5)'}>
|
||||||
<ExitIcon />
|
<ExitIcon />
|
||||||
</IconButton>
|
</button>
|
||||||
|
|
||||||
{/* Hauptbild */}
|
{/* Hauptbild */}
|
||||||
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
|
<img src={getImageSrc(currentImage, false)} alt={currentImage.originalName} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', transition: `opacity ${TRANSITION_TIME}ms ease-in-out`, opacity: fadeOut ? 0 : 1 }} />
|
||||||
|
|
||||||
{/* Bildbeschreibung (wenn vorhanden) */}
|
{/* Bildbeschreibung (wenn vorhanden) */}
|
||||||
{currentImage.imageDescription && (
|
{currentImage.imageDescription && (
|
||||||
<Box sx={imageDescriptionSx}>
|
<div style={{ position: 'fixed', bottom: '140px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'rgba(0,0,0,0.7)', padding: '15px 30px', borderRadius: '8px', maxWidth: '80%', textAlign: 'center', backdropFilter: 'blur(5px)', zIndex: 10002 }}>
|
||||||
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography>
|
<p style={{ color: 'white', fontSize: '18px', margin: 0, lineHeight: 1.4, fontFamily: 'Open Sans, sans-serif' }}>{currentImage.imageDescription}</p>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
<Box sx={descriptionContainerSx}>
|
<div style={{ position: 'fixed', left: '40px', bottom: '40px', backgroundColor: 'rgba(0,0,0,0.8)', padding: '25px 35px', borderRadius: '12px', maxWidth: '35vw', minWidth: '260px', textAlign: 'left', backdropFilter: 'blur(5px)', zIndex: 10001, boxShadow: '0 4px 24px rgba(0,0,0,0.4)' }}>
|
||||||
{/* Titel */}
|
{/* Titel */}
|
||||||
<Typography sx={titleTextSx}>{currentGroup.title || 'Unbenanntes Projekt'}</Typography>
|
<h2 style={{ color: 'white', fontSize: '28px', fontWeight: 500, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.title || 'Unbenanntes Projekt'}</h2>
|
||||||
|
|
||||||
{/* Jahr und Name */}
|
{/* Jahr und Name */}
|
||||||
<Typography sx={yearAuthorTextSx}>{currentGroup.year}{currentGroup.name && ` • ${currentGroup.name}`}</Typography>
|
<p style={{ color: '#FFD700', fontSize: '18px', fontWeight: 400, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif' }}>{currentGroup.year}{currentGroup.name && ` • ${currentGroup.name}`}</p>
|
||||||
|
|
||||||
{/* Beschreibung (wenn vorhanden) */}
|
{/* Beschreibung (wenn vorhanden) */}
|
||||||
{currentGroup.description && <Typography sx={descriptionTextSx}>{currentGroup.description}</Typography>}
|
{currentGroup.description && <p style={{ color: '#E0E0E0', fontSize: '16px', fontWeight: 300, marginBottom: '8px', marginTop: 0, fontFamily: 'Open Sans, sans-serif', lineHeight: 1.4 }}>{currentGroup.description}</p>}
|
||||||
|
|
||||||
{/* Meta-Informationen */}
|
{/* Meta-Informationen */}
|
||||||
<Typography sx={metaTextSx}>Bild {currentImageIndex + 1} von {currentGroup.images.length} • Slideshow {currentGroupIndex + 1} von {allGroups.length}</Typography>
|
<p style={{ color: '#999', fontSize: '12px', marginTop: '8px', marginBottom: 0, fontFamily: 'Open Sans, sans-serif' }}>Bild {currentImageIndex + 1} von {currentGroup.images.length} • Slideshow {currentGroupIndex + 1} von {allGroups.length}</p>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
62
frontend/src/Utils/apiClient.js
Normal file
62
frontend/src/Utils/apiClient.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios instance with error handling interceptors
|
||||||
|
* Handles HTTP status codes and redirects to appropriate error pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: window._env_?.API_URL || '',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
withCredentials: true, // For session cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor for error handling
|
||||||
|
*/
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// Pass through successful responses
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status } = error.response;
|
||||||
|
|
||||||
|
// Handle specific HTTP status codes
|
||||||
|
switch (status) {
|
||||||
|
case 403:
|
||||||
|
// Forbidden - redirect to 403 page
|
||||||
|
window.location.href = '/error/403';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
// Internal Server Error - redirect to 500 page
|
||||||
|
window.location.href = '/error/500';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 502:
|
||||||
|
// Bad Gateway - redirect to 502 page
|
||||||
|
window.location.href = '/error/502';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 503:
|
||||||
|
// Service Unavailable - redirect to 503 page
|
||||||
|
window.location.href = '/error/503';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For other errors, just reject the promise
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always reject the promise so calling code can handle it
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
116
frontend/src/Utils/apiFetch.js
Normal file
116
frontend/src/Utils/apiFetch.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Enhanced Fetch Wrapper with Error Handling
|
||||||
|
* Automatically redirects to error pages based on HTTP status codes
|
||||||
|
*
|
||||||
|
* Note: adminApi.js uses its own adminFetch wrapper for CSRF token handling
|
||||||
|
* and should not be migrated to this wrapper.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const handleErrorResponse = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 403:
|
||||||
|
window.location.href = '/error/403';
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
window.location.href = '/error/500';
|
||||||
|
break;
|
||||||
|
case 502:
|
||||||
|
window.location.href = '/error/502';
|
||||||
|
break;
|
||||||
|
case 503:
|
||||||
|
window.location.href = '/error/503';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Don't redirect for other errors (400, 401, etc.)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced fetch with automatic error page redirects
|
||||||
|
* @param {string} url - The URL to fetch
|
||||||
|
* @param {object} options - Fetch options
|
||||||
|
* @returns {Promise<Response>} - The response object
|
||||||
|
*/
|
||||||
|
export const apiFetch = async (url, options = {}) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
credentials: options.credentials || 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
// If response is not ok, handle error
|
||||||
|
if (!response.ok) {
|
||||||
|
handleErrorResponse(response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Network errors or other fetch failures
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for GET requests
|
||||||
|
*/
|
||||||
|
export const apiGet = async (url) => {
|
||||||
|
const response = await apiFetch(url, { method: 'GET' });
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for POST requests
|
||||||
|
*/
|
||||||
|
export const apiPost = async (url, body = null, options = {}) => {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(url, fetchOptions);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for PUT requests
|
||||||
|
*/
|
||||||
|
export const apiPut = async (url, body = null, options = {}) => {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(url, fetchOptions);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for DELETE requests
|
||||||
|
*/
|
||||||
|
export const apiDelete = async (url, options = {}) => {
|
||||||
|
const response = await apiFetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default apiFetch;
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { apiFetch } from './apiFetch';
|
||||||
|
|
||||||
// Batch-Upload Funktion für mehrere Bilder
|
// Batch-Upload Funktion für mehrere Bilder
|
||||||
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
|
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
|
||||||
if (!images || images.length === 0) {
|
if (!images || images.length === 0) {
|
||||||
|
|
@ -29,7 +31,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/upload/batch', {
|
const response = await apiFetch('/api/upload/batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
@ -50,7 +52,7 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {
|
||||||
// Einzelne Gruppe abrufen
|
// Einzelne Gruppe abrufen
|
||||||
export const fetchGroup = async (groupId) => {
|
export const fetchGroup = async (groupId) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/groups/${groupId}`);
|
const response = await apiFetch(`/api/groups/${groupId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
|
@ -67,7 +69,7 @@ export const fetchGroup = async (groupId) => {
|
||||||
// Alle Gruppen abrufen
|
// Alle Gruppen abrufen
|
||||||
export const fetchAllGroups = async () => {
|
export const fetchAllGroups = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/groups');
|
const response = await apiFetch('/api/groups');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
|
@ -84,7 +86,7 @@ export const fetchAllGroups = async () => {
|
||||||
// Gruppe löschen
|
// Gruppe löschen
|
||||||
export const deleteGroup = async (groupId) => {
|
export const deleteGroup = async (groupId) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/groups/${groupId}`, {
|
const response = await apiFetch(`/api/groups/${groupId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
94
frontend/src/Utils/hostDetection.js
Normal file
94
frontend/src/Utils/hostDetection.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Host Detection Utility
|
||||||
|
*
|
||||||
|
* Erkennt, ob App auf public oder internal Host läuft
|
||||||
|
* Basiert auf window.location.hostname + env-config
|
||||||
|
*
|
||||||
|
* @module Utils/hostDetection
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hole Host-Konfiguration und Feature-Flags
|
||||||
|
* @returns {Object} Host-Config mit Feature-Flags
|
||||||
|
*/
|
||||||
|
export const getHostConfig = () => {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
|
||||||
|
// Hole Hosts aus Runtime-Config (wird von env.sh beim Container-Start gesetzt)
|
||||||
|
const publicHost = window._env_?.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
|
||||||
|
const internalHost = window._env_?.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
|
||||||
|
|
||||||
|
// Bestimme Host-Typ
|
||||||
|
const isPublic = hostname === publicHost;
|
||||||
|
const isInternal = hostname === internalHost || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
// Feature Flags basierend auf Host
|
||||||
|
return {
|
||||||
|
hostname,
|
||||||
|
publicHost,
|
||||||
|
internalHost,
|
||||||
|
isPublic,
|
||||||
|
isInternal,
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
canAccessAdmin: isInternal,
|
||||||
|
canAccessSlideshow: isInternal,
|
||||||
|
canAccessGroups: isInternal,
|
||||||
|
canAccessModeration: isInternal,
|
||||||
|
canAccessReorder: isInternal,
|
||||||
|
canAccessBatchUpload: isInternal,
|
||||||
|
canAccessSocialMedia: isInternal,
|
||||||
|
canAccessMigration: isInternal,
|
||||||
|
|
||||||
|
// Immer erlaubt (public + internal)
|
||||||
|
canUpload: true,
|
||||||
|
canManageByUUID: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob App auf public Host läuft
|
||||||
|
* @returns {boolean} True wenn public Host
|
||||||
|
*/
|
||||||
|
export const isPublicHost = () => {
|
||||||
|
return getHostConfig().isPublic;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob App auf internal Host läuft
|
||||||
|
* @returns {boolean} True wenn internal Host
|
||||||
|
*/
|
||||||
|
export const isInternalHost = () => {
|
||||||
|
return getHostConfig().isInternal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hole spezifisches Feature-Flag
|
||||||
|
* @param {string} featureName - Name des Features (z.B. 'canAccessAdmin')
|
||||||
|
* @returns {boolean} True wenn Feature erlaubt
|
||||||
|
*/
|
||||||
|
export const canAccessFeature = (featureName) => {
|
||||||
|
const config = getHostConfig();
|
||||||
|
return config[featureName] || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-Funktion: Logge Host-Config in Console
|
||||||
|
* Nur in Development
|
||||||
|
*/
|
||||||
|
export const logHostConfig = () => {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const config = getHostConfig();
|
||||||
|
console.log('🔍 Host Configuration:', {
|
||||||
|
hostname: config.hostname,
|
||||||
|
isPublic: config.isPublic,
|
||||||
|
isInternal: config.isInternal,
|
||||||
|
features: {
|
||||||
|
admin: config.canAccessAdmin,
|
||||||
|
slideshow: config.canAccessSlideshow,
|
||||||
|
groups: config.canAccessGroups,
|
||||||
|
moderation: config.canAccessModeration
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios from 'axios'
|
import apiClient from './apiClient'
|
||||||
|
|
||||||
//import swal from 'sweetalert';
|
//import swal from 'sweetalert';
|
||||||
import Swal from 'sweetalert2/dist/sweetalert2.js'
|
import Swal from 'sweetalert2/dist/sweetalert2.js'
|
||||||
|
|
@ -22,7 +22,7 @@ export async function sendRequest(file, handleLoading, handleResponse) {
|
||||||
handleLoading()
|
handleLoading()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(window._env_.API_URL + '/upload', formData, {
|
const res = await apiClient.post('/upload', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data"
|
"Content-Type": "multipart/form-data"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29468
package-lock.json
generated
Normal file
29468
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "project-image-uploader",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"release": "./scripts/release.sh patch",
|
||||||
|
"release:patch": "./scripts/release.sh patch",
|
||||||
|
"release:minor": "./scripts/release.sh minor",
|
||||||
|
"release:major": "./scripts/release.sh major"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"frontend",
|
||||||
|
"backend"
|
||||||
|
]
|
||||||
|
}
|
||||||
32
prod.sh
32
prod.sh
|
|
@ -52,8 +52,36 @@ case $choice in
|
||||||
;;
|
;;
|
||||||
3)
|
3)
|
||||||
echo -e "${GREEN}Pushe Production Images zur Registry...${NC}"
|
echo -e "${GREEN}Pushe Production Images zur Registry...${NC}"
|
||||||
docker compose -f docker/prod/docker-compose.yml push
|
|
||||||
echo -e "${GREEN}Production Images erfolgreich gepusht!${NC}"
|
# Hole aktuelle Version aus package.json
|
||||||
|
VERSION=$(node -p "require('./frontend/package.json').version")
|
||||||
|
REGISTRY="gitea.lan.hobbyhimmel.de/hobbyhimmel"
|
||||||
|
echo -e "${BLUE}Aktuelle Version: ${VERSION}${NC}"
|
||||||
|
echo -e "${BLUE}Registry: ${REGISTRY}${NC}"
|
||||||
|
|
||||||
|
# Baue Images mit korrekter Version
|
||||||
|
echo -e "${BLUE}Baue Images mit Version ${VERSION}...${NC}"
|
||||||
|
cd docker/prod
|
||||||
|
docker build -t ${REGISTRY}/image-uploader-frontend:${VERSION} -f frontend/Dockerfile ../../
|
||||||
|
docker build -t ${REGISTRY}/image-uploader-backend:${VERSION} -f backend/Dockerfile ../../
|
||||||
|
|
||||||
|
# Tag als 'latest'
|
||||||
|
docker tag ${REGISTRY}/image-uploader-frontend:${VERSION} ${REGISTRY}/image-uploader-frontend:latest
|
||||||
|
docker tag ${REGISTRY}/image-uploader-backend:${VERSION} ${REGISTRY}/image-uploader-backend:latest
|
||||||
|
|
||||||
|
# Push Images (max-concurrent-uploads=1 in Docker Desktop gesetzt)
|
||||||
|
echo -e "${BLUE}Pushe Frontend ${VERSION}...${NC}"
|
||||||
|
docker push ${REGISTRY}/image-uploader-frontend:${VERSION}
|
||||||
|
echo -e "${BLUE}Pushe Frontend latest...${NC}"
|
||||||
|
docker push ${REGISTRY}/image-uploader-frontend:latest
|
||||||
|
|
||||||
|
echo -e "${BLUE}Pushe Backend ${VERSION}...${NC}"
|
||||||
|
docker push ${REGISTRY}/image-uploader-backend:${VERSION}
|
||||||
|
echo -e "${BLUE}Pushe Backend latest...${NC}"
|
||||||
|
docker push ${REGISTRY}/image-uploader-backend:latest
|
||||||
|
|
||||||
|
cd ../..
|
||||||
|
echo -e "${GREEN}✓ Images v${VERSION} und latest erfolgreich gepusht!${NC}"
|
||||||
;;
|
;;
|
||||||
4)
|
4)
|
||||||
echo -e "${GREEN}Baue Container neu...${NC}"
|
echo -e "${GREEN}Baue Container neu...${NC}"
|
||||||
|
|
|
||||||
15
scripts/.env.telegram.example
Normal file
15
scripts/.env.telegram.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Telegram Bot Configuration Template
|
||||||
|
#
|
||||||
|
# Kopiere diese Datei zu .env.telegram und trage deine echten Werte ein:
|
||||||
|
# cp .env.telegram.example .env.telegram
|
||||||
|
#
|
||||||
|
# WICHTIG: .env.telegram NIEMALS committen! (ist in .gitignore)
|
||||||
|
|
||||||
|
# Bot-Token von @BotFather
|
||||||
|
# Beispiel: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
||||||
|
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Chat-ID der Telegram-Gruppe (negativ für Gruppen!)
|
||||||
|
# Ermitteln via: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||||
|
# Beispiel: -1001234567890
|
||||||
|
TELEGRAM_CHAT_ID=YOUR_CHAT_ID_HERE
|
||||||
|
|
@ -1,4 +1,120 @@
|
||||||
# Scripts Overview
|
# Scripts
|
||||||
|
|
||||||
|
## 🚀 Automated Release (EMPFOHLEN)
|
||||||
|
|
||||||
|
### Ein Befehl macht alles:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release # Patch: 1.2.0 → 1.2.1
|
||||||
|
npm run release:minor # Minor: 1.2.0 → 1.3.0
|
||||||
|
npm run release:major # Major: 1.2.0 → 2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert automatisch:**
|
||||||
|
1. ✅ Version in allen package.json erhöht
|
||||||
|
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
|
||||||
|
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
|
||||||
|
4. ✅ Git Commit erstellt
|
||||||
|
5. ✅ Git Tag erstellt
|
||||||
|
6. ✅ Preview anzeigen + Bestätigung
|
||||||
|
|
||||||
|
Dann nur noch:
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel-Workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Features entwickeln mit Conventional Commits:
|
||||||
|
git commit -m "feat: Add user login"
|
||||||
|
git commit -m "fix: Fix button alignment"
|
||||||
|
git commit -m "refactor: Extract ConsentFilter component"
|
||||||
|
|
||||||
|
# Release erstellen:
|
||||||
|
npm run release:minor
|
||||||
|
|
||||||
|
# Preview wird angezeigt, dann [Y] drücken
|
||||||
|
# Push:
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### CHANGELOG wird automatisch aus Commits generiert!
|
||||||
|
|
||||||
|
Das Script gruppiert deine Commits nach Typ:
|
||||||
|
- `feat:` → ✨ Features
|
||||||
|
- `fix:` → 🐛 Fixes
|
||||||
|
- `refactor:` → ♻️ Refactoring
|
||||||
|
- `chore:` → 🔧 Chores
|
||||||
|
- `docs:` → 📚 Documentation
|
||||||
|
|
||||||
|
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Scripts
|
||||||
|
|
||||||
|
Falls du manuell Kontrolle brauchst:
|
||||||
|
|
||||||
|
### Version Management
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Version erhöhen (patch: 1.2.0 → 1.2.1)
|
||||||
|
./scripts/bump-version.sh patch
|
||||||
|
|
||||||
|
# Version erhöhen (minor: 1.2.0 → 1.3.0)
|
||||||
|
./scripts/bump-version.sh minor
|
||||||
|
|
||||||
|
# Version erhöhen (major: 1.2.0 → 2.0.0)
|
||||||
|
./scripts/bump-version.sh major
|
||||||
|
|
||||||
|
# Nur synchronisieren (ohne Bump)
|
||||||
|
./scripts/sync-version.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. **Version erhöhen:**
|
||||||
|
```bash
|
||||||
|
./scripts/bump-version.sh patch # oder minor/major
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CHANGELOG.md manuell aktualisieren**
|
||||||
|
|
||||||
|
3. **Commit & Tag:**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: bump version to v1.2.1"
|
||||||
|
git tag v1.2.1
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was wird synchronisiert?
|
||||||
|
|
||||||
|
- ✅ `frontend/package.json` → **Single Source of Truth**
|
||||||
|
- ✅ `backend/package.json`
|
||||||
|
- ✅ `frontend/src/Components/ComponentUtils/Footer.js` (Fallback)
|
||||||
|
- ✅ `backend/src/generate-openapi.js` (API Version)
|
||||||
|
- ✅ Docker Images (falls vorhanden)
|
||||||
|
- ✅ OpenAPI Spec wird neu generiert
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
#### `bump-version.sh`
|
||||||
|
Erhöht die Version in `frontend/package.json` und ruft `sync-version.sh` auf.
|
||||||
|
|
||||||
|
**Parameter:** `patch` | `minor` | `major`
|
||||||
|
|
||||||
|
#### `sync-version.sh`
|
||||||
|
Synchronisiert die Version aus `frontend/package.json` zu allen anderen Dateien.
|
||||||
|
|
||||||
|
Kann auch manuell aufgerufen werden, wenn du die Version direkt in `frontend/package.json` geändert hast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other Scripts Overview
|
||||||
|
|
||||||
## Admin-Benutzer anlegen (Shell)
|
## Admin-Benutzer anlegen (Shell)
|
||||||
|
|
||||||
|
|
|
||||||
506
scripts/README.telegram.md
Normal file
506
scripts/README.telegram.md
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
# Telegram Bot Setup & Testing Guide
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt Schritt-für-Schritt, wie du den Telegram Bot für den Image Uploader erstellst und testest.
|
||||||
|
|
||||||
|
**Phase 1:** Standalone-Test (ohne Backend-Integration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- ✅ Windows 11 mit installiertem Telegram Desktop
|
||||||
|
- ✅ Telegram Account
|
||||||
|
- ✅ Node.js >= 18.x installiert
|
||||||
|
- ✅ Zugriff auf dieses Git-Repository
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Telegram Bot erstellen
|
||||||
|
|
||||||
|
### 1.1 BotFather öffnen
|
||||||
|
|
||||||
|
1. **Telegram Desktop** öffnen
|
||||||
|
2. In der Suche eingeben: `@BotFather`
|
||||||
|
3. Chat mit **BotFather** öffnen (offizieller Bot mit blauem Haken ✓)
|
||||||
|
|
||||||
|
### 1.2 Neuen Bot erstellen
|
||||||
|
|
||||||
|
**Commands im BotFather-Chat eingeben:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/newbot
|
||||||
|
```
|
||||||
|
|
||||||
|
**BotFather fragt nach Namen:**
|
||||||
|
```
|
||||||
|
Alright, a new bot. How are we going to call it?
|
||||||
|
Please choose a name for your bot.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antworten mit:**
|
||||||
|
```
|
||||||
|
Werkstatt Image Uploader Bot
|
||||||
|
```
|
||||||
|
|
||||||
|
**BotFather fragt nach Username:**
|
||||||
|
```
|
||||||
|
Good. Now let's choose a username for your bot.
|
||||||
|
It must end in `bot`. Like this, for example: TetrisBot or tetris_bot.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antworten mit (muss auf `bot` enden):**
|
||||||
|
```
|
||||||
|
werkstatt_uploader_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Falls Username vergeben:** Anderen Namen wählen (z.B. `werkstatt_upload_bot`, `hobbyhimmel_uploader_bot`)
|
||||||
|
|
||||||
|
### 1.3 Bot-Token speichern
|
||||||
|
|
||||||
|
**BotFather antwortet mit:**
|
||||||
|
```
|
||||||
|
Done! Congratulations on your new bot.
|
||||||
|
You will find it at t.me/werkstatt_uploader_bot.
|
||||||
|
You can now add a description, about section and profile picture for your bot.
|
||||||
|
|
||||||
|
Use this token to access the HTTP API:
|
||||||
|
123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890
|
||||||
|
|
||||||
|
Keep your token secure and store it safely, it can be used by anyone to control your bot.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token kopieren** (z.B. `123456789:ABCdefGHIjklMNOpqrsTUVwxyz1234567890`)
|
||||||
|
|
||||||
|
➡️ **Diesen Token brauchst du gleich für `.env.telegram`!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test-Telegram-Gruppe erstellen
|
||||||
|
|
||||||
|
### 2.1 Neue Gruppe erstellen
|
||||||
|
|
||||||
|
1. In Telegram: **Neuer Chat** → **Neue Gruppe**
|
||||||
|
2. Gruppennamen eingeben: `Werkstatt Upload Bot Test`
|
||||||
|
3. **Weiter** klicken
|
||||||
|
4. (Optional) Weitere Mitglieder hinzufügen (oder nur dich selbst)
|
||||||
|
5. **Erstellen** klicken
|
||||||
|
|
||||||
|
### 2.2 Bot zur Gruppe hinzufügen
|
||||||
|
|
||||||
|
1. Test-Gruppe öffnen
|
||||||
|
2. Auf Gruppennamen (oben) klicken → **Mitglieder hinzufügen**
|
||||||
|
3. Suche nach: `@werkstatt_uploader_bot` (dein Bot-Username)
|
||||||
|
4. Bot auswählen → **Hinzufügen**
|
||||||
|
|
||||||
|
**Telegram zeigt:**
|
||||||
|
```
|
||||||
|
werkstatt_uploader_bot wurde zur Gruppe hinzugefügt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Privacy Mode deaktivieren (WICHTIG!)
|
||||||
|
|
||||||
|
⚠️ **Ohne diesen Schritt sieht der Bot keine Gruppennachrichten!**
|
||||||
|
|
||||||
|
1. **BotFather** öffnen
|
||||||
|
2. Command eingeben: `/mybots`
|
||||||
|
3. Deinen Bot auswählen: `@werkstatt_uploader_bot`
|
||||||
|
4. **Bot Settings** klicken
|
||||||
|
5. **Group Privacy** klicken
|
||||||
|
6. **Turn off** klicken
|
||||||
|
|
||||||
|
**BotFather bestätigt:**
|
||||||
|
```
|
||||||
|
Privacy mode is disabled for <bot-name>.
|
||||||
|
All messages will now be sent to the bot.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Bot als Admin hinzufügen (optional, aber empfohlen)
|
||||||
|
|
||||||
|
1. Gruppe öffnen → Gruppennamen klicken
|
||||||
|
2. **Administratoren** → **Administrator hinzufügen**
|
||||||
|
3. `@werkstatt_uploader_bot` auswählen
|
||||||
|
4. **Berechtigungen:**
|
||||||
|
- ✅ Nachrichten senden
|
||||||
|
- ❌ Alle anderen optional (nicht nötig)
|
||||||
|
5. **Speichern**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Chat-ID ermitteln
|
||||||
|
|
||||||
|
Die Chat-ID wird benötigt, um Nachrichten an die richtige Gruppe zu senden.
|
||||||
|
|
||||||
|
### Methode 1: Via Telegram API (empfohlen)
|
||||||
|
|
||||||
|
**Schritt 1:** Nachricht in Test-Gruppe senden
|
||||||
|
|
||||||
|
- Öffne die Test-Gruppe
|
||||||
|
- Sende eine beliebige Nachricht (z.B. "Test")
|
||||||
|
|
||||||
|
**Schritt 2:** Browser öffnen und folgende URL aufrufen:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.telegram.org/bot<DEIN_BOT_TOKEN>/getUpdates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ersetze `<DEIN_BOT_TOKEN>`** mit deinem echten Token!
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/getUpdates
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Wenn du `{"ok":true,"result":[]}` siehst (leeres result-Array):**
|
||||||
|
|
||||||
|
Das bedeutet, der Bot hat noch keine Nachrichten empfangen. **Checkliste:**
|
||||||
|
|
||||||
|
1. ✅ Hast du den Bot zur Gruppe hinzugefügt? (Schritt 2.2)
|
||||||
|
2. ✅ Hast du **NACH** dem Hinzufügen eine Nachricht gesendet? (Schritt 1)
|
||||||
|
3. ✅ War die Nachricht in der **richtigen Gruppe** (nicht im Bot-Direct-Chat)?
|
||||||
|
|
||||||
|
**Lösung - Mach jetzt folgendes:**
|
||||||
|
- Telegram öffnen
|
||||||
|
- **Test-Gruppe** öffnen (nicht den Bot direkt!)
|
||||||
|
- Prüfe, ob der Bot als Mitglied angezeigt wird
|
||||||
|
- Sende eine neue Nachricht: "Test"
|
||||||
|
- **Sofort** zurück zum Browser → Seite neu laden (F5)
|
||||||
|
|
||||||
|
**Schritt 3:** Im JSON-Response nach `chat` suchen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"update_id": 123456789,
|
||||||
|
"message": {
|
||||||
|
"message_id": 1,
|
||||||
|
"from": { ... },
|
||||||
|
"chat": {
|
||||||
|
"id": -1001234567890, // ← DAS IST DEINE CHAT-ID!
|
||||||
|
"title": "Werkstatt Upload Bot Test",
|
||||||
|
"type": "supergroup"
|
||||||
|
},
|
||||||
|
"text": "Test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chat-ID kopieren** (z.B. `-1001234567890`)
|
||||||
|
|
||||||
|
⚠️ **Wichtig:** Gruppen-Chat-IDs sind **negativ** und beginnen meist mit `-100`!
|
||||||
|
|
||||||
|
### Methode 2: Via curl (Linux/WSL)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://api.telegram.org/bot<DEIN_BOT_TOKEN>/getUpdates" | jq '.result[0].message.chat.id'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment-Datei erstellen
|
||||||
|
|
||||||
|
### 4.1 Template kopieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
cp .env.telegram.example .env.telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `.env.telegram` bearbeiten
|
||||||
|
|
||||||
|
**Datei öffnen:** `scripts/.env.telegram`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=-1001234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deine echten Werte eintragen:**
|
||||||
|
- `TELEGRAM_BOT_TOKEN` → Token von BotFather (Schritt 1.3)
|
||||||
|
- `TELEGRAM_CHAT_ID` → Chat-ID aus Schritt 3 (negativ!)
|
||||||
|
|
||||||
|
**Speichern** und schließen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dependencies installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
|
||||||
|
# package.json initialisieren (falls nicht vorhanden)
|
||||||
|
npm init -y
|
||||||
|
|
||||||
|
# Telegram Bot API installieren
|
||||||
|
npm install node-telegram-bot-api dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartete Ausgabe:**
|
||||||
|
```
|
||||||
|
added 15 packages, and audited 16 packages in 2s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test-Script ausführen
|
||||||
|
|
||||||
|
### 6.1 Script starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node telegram-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Erwartete Ausgabe (Erfolg)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔧 Lade Telegram-Konfiguration...
|
||||||
|
✅ Konfiguration geladen!
|
||||||
|
|
||||||
|
🤖 Verbinde mit Telegram Bot...
|
||||||
|
✅ Telegram Bot erfolgreich verbunden!
|
||||||
|
|
||||||
|
Bot-Details:
|
||||||
|
Name: Werkstatt Image Uploader Bot
|
||||||
|
Username: @werkstatt_uploader_bot
|
||||||
|
ID: 1234567890
|
||||||
|
|
||||||
|
📤 Sende Test-Nachricht an Chat -1001234567890...
|
||||||
|
✅ Nachricht erfolgreich gesendet!
|
||||||
|
|
||||||
|
Message-ID: 42
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Telegram-Gruppe prüfen
|
||||||
|
|
||||||
|
**In der Test-Gruppe sollte jetzt erscheinen:**
|
||||||
|
|
||||||
|
```
|
||||||
|
🤖 Telegram Bot Test
|
||||||
|
|
||||||
|
Dies ist eine Test-Nachricht vom Werkstatt Image Uploader Bot.
|
||||||
|
|
||||||
|
Status: ✅ Erfolgreich verbunden!
|
||||||
|
Zeitstempel: 2025-11-29 14:23:45
|
||||||
|
|
||||||
|
---
|
||||||
|
ℹ️ Dieser Bot sendet automatische Benachrichtigungen für den Image Uploader.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Troubleshooting
|
||||||
|
|
||||||
|
### ❌ Fehler: "Error: Unauthorized (401)"
|
||||||
|
|
||||||
|
**Ursache:** Bot-Token ist falsch oder ungültig
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. BotFather öffnen
|
||||||
|
2. `/token` eingeben
|
||||||
|
3. Deinen Bot auswählen
|
||||||
|
4. Neuen Token kopieren
|
||||||
|
5. `.env.telegram` aktualisieren
|
||||||
|
6. Script erneut starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ Fehler: "Bad Request: chat not found"
|
||||||
|
|
||||||
|
**Ursache:** Chat-ID ist falsch
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Test-Gruppe öffnen
|
||||||
|
2. Neue Nachricht senden
|
||||||
|
3. Chat-ID erneut ermitteln (Schritt 3)
|
||||||
|
4. `.env.telegram` aktualisieren
|
||||||
|
5. Script erneut starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ Fehler: "Error: ETELEGRAM: 403 Forbidden"
|
||||||
|
|
||||||
|
**Ursache:** Bot wurde aus der Gruppe entfernt oder kann nicht posten
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Test-Gruppe öffnen
|
||||||
|
2. Prüfen, ob Bot noch Mitglied ist
|
||||||
|
3. Falls nicht: Bot erneut hinzufügen (Schritt 2.2)
|
||||||
|
4. Falls ja: Bot als Admin hinzufügen (Schritt 2.3)
|
||||||
|
5. Script erneut starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ Fehler: "Cannot find module 'node-telegram-bot-api'"
|
||||||
|
|
||||||
|
**Ursache:** Dependencies nicht installiert
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
npm install
|
||||||
|
node telegram-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ Fehler: "TELEGRAM_BOT_TOKEN is not defined"
|
||||||
|
|
||||||
|
**Ursache:** `.env.telegram` fehlt oder nicht korrekt
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Prüfen, ob `.env.telegram` existiert: `ls -la scripts/.env.telegram`
|
||||||
|
2. Falls nicht: Template kopieren (Schritt 4.1)
|
||||||
|
3. Werte eintragen (Schritt 4.2)
|
||||||
|
4. Script erneut starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Erweiterte Tests
|
||||||
|
|
||||||
|
### Test 1: Formatierung mit Emojis
|
||||||
|
|
||||||
|
**Script anpassen:** `telegram-test.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const message = `
|
||||||
|
📸 Neuer Upload!
|
||||||
|
|
||||||
|
Uploader: Max Mustermann
|
||||||
|
Bilder: 12
|
||||||
|
Gruppe: 2024 - Schweißkurs November
|
||||||
|
|
||||||
|
Workshop: ✅ Ja
|
||||||
|
Social Media: 📘 Instagram, 🎵 TikTok
|
||||||
|
|
||||||
|
🔗 Zur Freigabe: https://internal.hobbyhimmel.de/moderation
|
||||||
|
`;
|
||||||
|
|
||||||
|
bot.sendMessage(chatId, message);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausführen:**
|
||||||
|
```bash
|
||||||
|
node telegram-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Telegram prüfen:** Emojis sollten korrekt angezeigt werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 2: HTML-Formatierung
|
||||||
|
|
||||||
|
**Script anpassen:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const message = `
|
||||||
|
<b>🤖 Telegram Bot Test</b>
|
||||||
|
|
||||||
|
<i>HTML-Formatierung funktioniert!</i>
|
||||||
|
|
||||||
|
<code>Status: ✅ Erfolgreich</code>
|
||||||
|
|
||||||
|
<a href="https://hobbyhimmel.de">Link zur Website</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
bot.sendMessage(chatId, message, { parse_mode: 'HTML' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ausführen & prüfen:** Fetter Text, kursiver Text, Code, Link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 3: Markdown-Formatierung
|
||||||
|
|
||||||
|
**Script anpassen:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const message = `
|
||||||
|
*🤖 Telegram Bot Test*
|
||||||
|
|
||||||
|
_Markdown-Formatierung funktioniert!_
|
||||||
|
|
||||||
|
\`Status: ✅ Erfolgreich\`
|
||||||
|
|
||||||
|
[Link zur Website](https://hobbyhimmel.de)
|
||||||
|
`;
|
||||||
|
|
||||||
|
bot.sendMessage(chatId, message, { parse_mode: 'Markdown' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sicherheit
|
||||||
|
|
||||||
|
### ⚠️ Wichtig!
|
||||||
|
|
||||||
|
- ❌ **NIEMALS** `.env.telegram` committen!
|
||||||
|
- ❌ **NIEMALS** Bot-Token öffentlich teilen!
|
||||||
|
- ✅ `.env.telegram` ist in `.gitignore` eingetragen
|
||||||
|
- ✅ Nur `.env.telegram.example` (ohne echte Tokens) committen
|
||||||
|
|
||||||
|
### Bot-Token kompromittiert?
|
||||||
|
|
||||||
|
**Falls Token versehentlich exposed:**
|
||||||
|
|
||||||
|
1. BotFather öffnen
|
||||||
|
2. `/revoke` eingeben
|
||||||
|
3. Deinen Bot auswählen
|
||||||
|
4. **Neuen Token** kopieren
|
||||||
|
5. `.env.telegram` aktualisieren
|
||||||
|
6. Alle Services neu starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Nächste Schritte
|
||||||
|
|
||||||
|
### ✅ Phase 1 abgeschlossen?
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [x] Bot erstellt
|
||||||
|
- [x] Test-Gruppe erstellt
|
||||||
|
- [x] Bot zur Gruppe hinzugefügt
|
||||||
|
- [x] Chat-ID ermittelt
|
||||||
|
- [x] `.env.telegram` konfiguriert
|
||||||
|
- [x] `npm install` erfolgreich
|
||||||
|
- [x] `node telegram-test.js` läuft ohne Fehler
|
||||||
|
- [x] Test-Nachricht in Telegram empfangen
|
||||||
|
|
||||||
|
### ➡️ Weiter zu Phase 2
|
||||||
|
|
||||||
|
**Backend-Integration:**
|
||||||
|
1. `TelegramNotificationService.js` erstellen
|
||||||
|
2. Service in Docker Dev Environment integrieren
|
||||||
|
3. ENV-Variablen in Backend übertragen
|
||||||
|
4. Unit-Tests schreiben
|
||||||
|
|
||||||
|
**Siehe:** `FeatureRequests/FEATURE_PLAN-telegram.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- [Telegram Bot API Dokumentation](https://core.telegram.org/bots/api)
|
||||||
|
- [node-telegram-bot-api (npm)](https://www.npmjs.com/package/node-telegram-bot-api)
|
||||||
|
- [BotFather Commands](https://core.telegram.org/bots#botfather)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
**Bei Problemen:**
|
||||||
|
1. Troubleshooting-Sektion durchlesen (Schritt 7)
|
||||||
|
2. Telegram Bot API Logs prüfen
|
||||||
|
3. BotFather `/mybots` → Bot auswählen → API Token prüfen
|
||||||
|
4. Chat-ID erneut ermitteln
|
||||||
|
|
||||||
|
**Erfolgreicher Test? 🎉**
|
||||||
|
```bash
|
||||||
|
git add scripts/
|
||||||
|
git commit -m "feat: Add Telegram Bot standalone test (Phase 1)"
|
||||||
|
```
|
||||||
38
scripts/bump-version.sh
Executable file
38
scripts/bump-version.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Bumpt die Version und synchronisiert alle Dateien
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION_TYPE=${1:-patch} # patch, minor, major
|
||||||
|
|
||||||
|
if [[ ! "$VERSION_TYPE" =~ ^(patch|minor|major)$ ]]; then
|
||||||
|
echo "❌ Ungültiger Version-Typ: $VERSION_TYPE"
|
||||||
|
echo "Verwendung: ./scripts/bump-version.sh [patch|minor|major]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${BLUE}🚀 Version Bump: ${YELLOW}${VERSION_TYPE}${NC}"
|
||||||
|
|
||||||
|
# 1. Frontend Version bumpen (als Single Source of Truth)
|
||||||
|
echo " ├─ Bumpe Frontend Version..."
|
||||||
|
cd frontend
|
||||||
|
npm version $VERSION_TYPE --no-git-tag-version
|
||||||
|
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo -e "${GREEN} ✓ Neue Version: ${NEW_VERSION}${NC}"
|
||||||
|
|
||||||
|
# 2. Alle anderen Stellen synchronisieren
|
||||||
|
./scripts/sync-version.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Version erfolgreich auf v${NEW_VERSION} erhöht!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Vergiss nicht:"
|
||||||
|
echo " 1. CHANGELOG.md für v${NEW_VERSION} aktualisieren"
|
||||||
|
echo " 2. Commit & Tag erstellen"
|
||||||
0
scripts/examples.sh
Normal file → Executable file
0
scripts/examples.sh
Normal file → Executable file
|
|
@ -5,6 +5,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
TARGET_FILE="$ROOT_DIR/docker/prod/docker-compose.yml"
|
TARGET_FILE="$ROOT_DIR/docker/prod/docker-compose.yml"
|
||||||
ANCHOR_LINE=" - ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions"
|
ANCHOR_LINE=" - ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions"
|
||||||
EXPECTED_LINE=" - ADMIN_SESSION_COOKIE_SECURE=true"
|
EXPECTED_LINE=" - ADMIN_SESSION_COOKIE_SECURE=true"
|
||||||
|
SECRET_ANCHOR_LINE=' - NODE_ENV=production'
|
||||||
|
SECRET_EXPECTED_LINE=' - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}'
|
||||||
|
SECRET_VALUE='${ADMIN_SESSION_SECRET}'
|
||||||
|
|
||||||
if [[ ! -f "$TARGET_FILE" ]]; then
|
if [[ ! -f "$TARGET_FILE" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -13,6 +16,9 @@ fi
|
||||||
export TARGET_FILE
|
export TARGET_FILE
|
||||||
export ANCHOR_LINE
|
export ANCHOR_LINE
|
||||||
export EXPECTED_LINE
|
export EXPECTED_LINE
|
||||||
|
export SECRET_ANCHOR_LINE
|
||||||
|
export SECRET_EXPECTED_LINE
|
||||||
|
export SECRET_VALUE
|
||||||
|
|
||||||
result=$(python3 <<'PY'
|
result=$(python3 <<'PY'
|
||||||
import os
|
import os
|
||||||
|
|
@ -23,30 +29,76 @@ import sys
|
||||||
path = pathlib.Path(os.environ['TARGET_FILE'])
|
path = pathlib.Path(os.environ['TARGET_FILE'])
|
||||||
anchor = os.environ['ANCHOR_LINE']
|
anchor = os.environ['ANCHOR_LINE']
|
||||||
expected = os.environ['EXPECTED_LINE']
|
expected = os.environ['EXPECTED_LINE']
|
||||||
|
secret_anchor = os.environ['SECRET_ANCHOR_LINE']
|
||||||
|
secret_expected = os.environ['SECRET_EXPECTED_LINE']
|
||||||
|
secret_value = os.environ['SECRET_VALUE']
|
||||||
|
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
|
new_text = text
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
if 'ADMIN_SESSION_COOKIE_SECURE' in text:
|
cookie_pattern = re.compile(r'(\-\s*ADMIN_SESSION_COOKIE_SECURE\s*=\s*)([^\n\r]+)')
|
||||||
pattern = re.compile(r'(\-\s*ADMIN_SESSION_COOKIE_SECURE\s*=\s*)([^\n\r]+)')
|
secret_pattern = re.compile(r'(\-\s*ADMIN_SESSION_SECRET\s*=\s*)([^\n\r]+)')
|
||||||
new_text, count = pattern.subn(r'\1true', text, count=1)
|
telegram_token_pattern = re.compile(r'(\-\s*TELEGRAM_BOT_TOKEN\s*=\s*)([^\n\r${}]+)')
|
||||||
if count:
|
telegram_chat_pattern = re.compile(r'(\-\s*TELEGRAM_CHAT_ID\s*=\s*)(-?\d{10,})')
|
||||||
changed = new_text != text
|
|
||||||
else:
|
def ensure_entry(text, *, pattern, value, anchor_line, expected_line, label):
|
||||||
if anchor not in text:
|
match = pattern.search(text)
|
||||||
print('ERROR: Anchor line not found for ADMIN_SESSION_COOKIE_SECURE insertion', file=sys.stderr)
|
if match:
|
||||||
sys.exit(2)
|
desired = f"{match.group(1)}{value}"
|
||||||
new_text = text.replace(anchor, anchor + '\n' + expected, 1)
|
if match.group(0) == desired:
|
||||||
changed = True
|
return text, False
|
||||||
|
return pattern.sub(lambda m: f"{m.group(1)}{value}", text, count=1), True
|
||||||
|
if anchor_line not in text:
|
||||||
|
print(f"ERROR: Anchor line not found for {label}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
return text.replace(anchor_line, anchor_line + '\n' + expected_line, 1), True
|
||||||
|
|
||||||
|
new_text, cookie_changed = ensure_entry(
|
||||||
|
new_text,
|
||||||
|
pattern=cookie_pattern,
|
||||||
|
value='true',
|
||||||
|
anchor_line=anchor,
|
||||||
|
expected_line=expected,
|
||||||
|
label='ADMIN_SESSION_COOKIE_SECURE'
|
||||||
|
)
|
||||||
|
changed = changed or cookie_changed
|
||||||
|
|
||||||
if expected not in new_text:
|
if expected not in new_text:
|
||||||
print('ERROR: Failed to ensure ADMIN_SESSION_COOKIE_SECURE=true in docker-compose.yml', file=sys.stderr)
|
print('ERROR: Failed to ensure ADMIN_SESSION_COOKIE_SECURE=true in docker-compose.yml', file=sys.stderr)
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
|
new_text, secret_changed = ensure_entry(
|
||||||
|
new_text,
|
||||||
|
pattern=secret_pattern,
|
||||||
|
value=secret_value,
|
||||||
|
anchor_line=secret_anchor,
|
||||||
|
expected_line=secret_expected,
|
||||||
|
label='ADMIN_SESSION_SECRET'
|
||||||
|
)
|
||||||
|
changed = changed or secret_changed
|
||||||
|
|
||||||
|
if secret_expected not in new_text:
|
||||||
|
print('ERROR: Failed to ensure ADMIN_SESSION_SECRET uses environment variable in docker-compose.yml', file=sys.stderr)
|
||||||
|
sys.exit(4)
|
||||||
|
|
||||||
|
telegram_token_match = telegram_token_pattern.search(new_text)
|
||||||
|
if telegram_token_match and telegram_token_match.group(2).strip() not in ['${TELEGRAM_BOT_TOKEN}', '']:
|
||||||
|
print(f'ERROR: TELEGRAM_BOT_TOKEN contains hardcoded secret: {telegram_token_match.group(2)[:20]}...', file=sys.stderr)
|
||||||
|
print(' Use ${TELEGRAM_BOT_TOKEN} placeholder instead!', file=sys.stderr)
|
||||||
|
sys.exit(5)
|
||||||
|
|
||||||
|
telegram_chat_match = telegram_chat_pattern.search(new_text)
|
||||||
|
if telegram_chat_match:
|
||||||
|
print(f'ERROR: TELEGRAM_CHAT_ID contains hardcoded value: {telegram_chat_match.group(2)}', file=sys.stderr)
|
||||||
|
print(' Use ${TELEGRAM_CHAT_ID} placeholder instead!', file=sys.stderr)
|
||||||
|
sys.exit(6)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
path.write_text(new_text)
|
path.write_text(new_text)
|
||||||
print('UPDATED')
|
print('UPDATED')
|
||||||
else:
|
else:
|
||||||
print('UNCHANGED')
|
print('UNCHANGED')
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
status=$?
|
status=$?
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user