Compare commits

..

32 Commits

Author SHA1 Message Date
04b13872c9 chore: bump version to 2.0.1 2025-12-01 22:16:55 +01:00
0d24a5e74c docs: Update Text Upload Page 2025-12-01 22:14:45 +01:00
2acbc4e248 docs: Moved finisched FeatureRequest, Update README.md 2025-11-30 17:36:54 +01:00
27d8c73b5f chore: release v2.0.0
🔖 Version 2.0.0

###  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
2025-11-30 14:11:19 +01:00
46198ddfdd Merge branch 'feature/telegram-notifications' 2025-11-30 14:09:51 +01:00
6b603112de docs: README.md aktualisiert - ENV-Struktur & Telegram dokumentiert
- Docker Structure: Neue ENV-Verwaltung erklärt (2 zentrale .env Dateien)
- Environment Variables: Vollständige Tabelle mit allen Variablen
- Telegram-Konfiguration dokumentiert
- Phase 6 als abgeschlossen markiert in FEATURE_PLAN-telegram.md
2025-11-30 13:26:54 +01:00
dd71dcab44 feat: ENV-Struktur massiv vereinfacht (Phase 6)
- Von 16 .env Dateien auf 2 zentrale reduziert
  * docker/dev/.env - Development Secrets
  * docker/prod/.env - Production Secrets

- Alle ENV-Variablen jetzt in docker-compose.yml environment sections
- .env COPY aus allen Dockerfiles entfernt (wurden durch volume mounts überschrieben)
- Frontend env.sh umgeschrieben: Liest ENV-Variablen statt .env Datei
- CLIENT_URL komplett entfernt (wurde nirgendwo verwendet)

- Fix: management.js nutzt platform_name statt name (DB-Schema korrekt)

ENV-Handling jetzt deutlich einfacher und wartbarer!
Von 4 Frontend ENV-Variablen auf 3 reduziert (API_URL, PUBLIC_HOST, INTERNAL_HOST)
2025-11-30 13:19:24 +01:00
d76b4b2c9c docs(telegram): complete Phase 5 documentation and security improvements
- Updated README.md with Telegram features section in 'Latest Features'
- Added Telegram environment variables to Environment Variables table
- Updated FEATURE_PLAN-telegram.md: marked Phases 1-5 as completed
- Updated status table with completion dates (Phase 1-4: done, Phase 5: docs complete)

OpenAPI Documentation:
- Added swagger tags to reorder route (Management Portal)
- Added swagger tags to consent routes (Consent Management)
- Regenerated openapi.json with correct tags (no more 'default' category)

Environment Configuration:
- Updated .env.backend.example with Telegram variables and session secret
- Created docker/dev/.env.example with Telegram configuration template
- Created docker/prod/.env.example with production environment template
- Moved secrets from docker-compose.yml to .env files (gitignored)
- Changed docker/dev/docker-compose.yml to use placeholders: ${TELEGRAM_BOT_TOKEN}

Security Enhancements:
- Disabled test message on server start by default (TELEGRAM_SEND_TEST_ON_START=false)
- Extended pre-commit hook to detect hardcoded Telegram secrets
- Hook prevents commit if TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID are hardcoded
- All secrets must use environment variable placeholders

Phase 5 fully completed and documented.
2025-11-30 11:40:59 +01:00
489e2166bb feat(telegram): add daily deletion warning cron job (Phase 5)
- Added Telegram warning cron job at 09:00 (1 hour before cleanup)
- Integrated with GroupCleanupService.findGroupsForDeletion()
- Sends sendDeletionWarning() notification for groups pending deletion
- Added manual trigger method triggerTelegramWarningNow() for development
- Added POST /api/admin/telegram/warning endpoint for manual testing
- Fixed SchedulerService singleton instance in server.js app.set()
- Added Telegram ENV vars to docker-compose.yml environment section

Tested successfully with test data showing warning message in Telegram.
2025-11-30 11:20:10 +01:00
8cceb8e9a3 feat: Add consent change and deletion notifications (Phase 4)
- Integrate sendConsentChangeNotification() into management.js PUT /consents
- Integrate sendGroupDeletedNotification() into management.js DELETE /:token
- Refactor sendConsentChangeNotification() to accept structured changeData
- Add platform name lookup for social media consent notifications
- Non-blocking async notifications (won't fail consent changes on error)

Phase 4 complete: Tested successfully with:
- Workshop consent revoke → Telegram notification received
- Group deletion → Telegram notification received

Changes:
- Workshop consent: Shows action (revoke/restore) and new status
- Social media consent: Shows platform and action
- Deletion: Shows uploader, year, title, image count
2025-11-30 10:22:52 +01:00
62be18ecaa feat: Add upload notifications to Telegram Bot (Phase 3)
- Integrate TelegramNotificationService into batchUpload route
- Send notification on successful upload with group details
- Add metadata parsing for year/title/name from form fields
- Create integration tests for upload notifications
- Fix getAdminUrl() to use INTERNAL_HOST with dev port
- Update jest.config.js to transform uuid ESM module
- Non-blocking async notification (won't fail upload on error)

Phase 3 complete: Upload notifications working in Docker dev environment
Tested successfully with real Telegram bot in test group
2025-11-29 23:47:01 +01:00
025578fa3d feat: Add TelegramNotificationService (Phase 2)
- Create TelegramNotificationService with all notification methods
- Add node-telegram-bot-api dependency
- Integrate service into server.js (auto-test on dev startup)
- Add ENV variables to docker/dev/backend/config/.env
- Create unit tests (10/14 passing - mock issues for 4)
- Update README.dev.md with Telegram testing guide

Service Features:
- sendTestMessage() - Test connection
- sendUploadNotification() - Phase 3 ready
- sendConsentChangeNotification() - Phase 4 ready
- sendGroupDeletedNotification() - Phase 4 ready
- sendDeletionWarning() - Phase 5 ready

Phase 2 complete: Backend service ready for integration.
2025-11-29 22:41:38 +01:00
15833dec83 chore: Add package.json for Telegram test scripts 2025-11-29 20:06:38 +01:00
86ace42fca feat: Add Telegram Bot standalone test (Phase 1)
- Add FEATURE_PLAN-telegram.md with 6-phase implementation roadmap
- Add scripts/README.telegram.md with step-by-step setup guide
- Add scripts/telegram-test.js standalone test script
- Add scripts/.env.telegram.example template for credentials
- Update .gitignore to protect Telegram credentials

Phase 1 complete: Bot setup, privacy mode configuration, and successful test message delivery.
2025-11-29 20:05:40 +01:00
b2386e7f11 feat: Add Telegram notification feature request and improve prod.sh Docker registry push 2025-11-29 19:28:23 +01:00
52125397bf chore: release v1.10.2
🔖 Version 1.10.2

###  Features
- Auto-push releases with --follow-tags
2025-11-29 17:47:55 +01:00
aea21622f7 feat: Auto-push releases with --follow-tags 2025-11-29 17:47:01 +01:00
bd10f6533e chore: release v1.10.1
🔖 Version 1.10.1

### 🐛 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
2025-11-29 17:34:25 +01:00
bf26472ea3 refactor: Use package.json version directly in Footer instead of env variables 2025-11-29 17:14:24 +01:00
ec3d7ee4d0 fix: Update Footer.js version to 1.10.0 and fix sync-version.sh regex 2025-11-29 17:02:40 +01:00
8818d2987d chore: release v1.10.0
🔖 Version 1.10.0

###  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
2025-11-29 16:57:14 +01:00
40aa546498 chore: Improve release script with tag-based commit detection
- Add helpful warning when no previous tag exists
- Show which tag is being used for commit range
- Provide tip for creating retroactive tags
- Fix typo in git log command (--online -> --oneline)
2025-11-29 16:52:19 +01:00
e4712f9e7e refactor: Extract ConsentFilter and StatsDisplay components from ModerationGroupsPage
- Created ConsentFilter component with proper styling
- Created StatsDisplay component for statistics display
- Added ModerationGroupsPage.css to remove inline styles
- Removed 83 lines of inline CSS from ModerationGroupsPage
- Components now reusable across admin pages
- Added container wrappers and titles to both components
- Improved code maintainability and separation of concerns
2025-11-29 15:21:51 +01:00
e4a76a6b3d refactor: Consolidate error pages into single ErrorPage component
- Created generic ErrorPage.js with errorCode prop
- Centralized error messages in ERROR_MESSAGES dictionary
- Updated App.js to use ErrorPage for all error routes
- Updated ErrorBoundary.js to use new ErrorPage component
- Removed duplicate files: 403Page.js, 404Page.js, 500Page.js, 502Page.js, 503Page.js
- Fixed 403/404 routing: protected routes show 403, unknown routes show 404
- Error pages now vertically centered with min-height: 100vh
2025-11-29 12:17:51 +01:00
91d6d06687 feat: Enable drag-and-drop reordering in ModerationGroupImagesPage
- Added PUT /api/admin/groups/:groupId/reorder endpoint
- Implemented handleReorder in ModerationGroupImagesPage
- Uses adminRequest API with proper error handling
- Same mobile touch support as ManagementPortalPage
2025-11-27 20:09:08 +01:00
215acaa67f refactor: Centralized styling with CSS and global MUI overrides
- Migrated all Pages from Material-UI to HTML+CSS (GroupsOverviewPage, ManagementPortalPage, ModerationGroupImagesPage, ModerationGroupsPage, PublicGroupImagesPage, SlideshowPage, MultiUploadPage)
- Added comprehensive typography system in App.css (h1-h3, p, utility classes)
- Added global Material-UI font overrides for Open Sans
- Removed redundant fontFamily: 'roboto' from all components
- Fixed button alignment in ImageGalleryCard (margin-top: auto)
- Removed emojis from titles for cleaner UI
- Standardized button padding (12px 30px) across application
- Improved code consistency and maintainability with centralized CSS approach
2025-11-27 19:47:39 +01:00
25dda32c4e feat: Error handling system and animated error pages
- Add ErrorBoundary component for React error handling
- Create animated error pages (403, 404, 500, 502, 503)
- Implement ErrorAnimation component with seven-segment display
- Add apiClient (axios) and apiFetch (fetch) wrappers with automatic error page redirects
- Migrate critical API calls to use new error handling
- Update font from Roboto to Open Sans across all components
- Remove unused CLIENT_URL from docker-compose files
- Rename 404Page.css to ErrorPage.css for consistency
- Add comprehensive ERROR_HANDLING.md documentation
2025-11-26 22:42:55 +01:00
920a81e075 Merge branch 'feature/public-internal-hosts' into main
Public/Internal Host Separation Feature

Implemented subdomain-based feature separation for production deployment with complete backend API protection, frontend code splitting, and local testing support.
2025-11-25 22:04:30 +01:00
e4ddd229b8 feat: Public/Internal Host Separation
Implemented subdomain-based feature separation for production deployment.

**Backend:**
- New hostGate middleware for host-based API protection
- Public host blocks: /api/admin, /api/groups, /api/slideshow, /api/auth
- Public host allows: /api/upload, /api/manage, /api/social-media/platforms
- Rate limiting: 20 uploads/hour on public host (publicUploadLimiter)
- Audit log enhancement: source_host, source_type tracking
- Database migration 009: Added source tracking columns

**Frontend:**
- Host detection utility (hostDetection.js) with feature flags
- React code splitting with lazy loading for internal features
- Conditional routing: Internal routes only mounted on internal host
- 404 page: Host-specific messaging and navbar
- Clipboard fallback for HTTP environments

**Configuration:**
- Environment variables: PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION
- Docker dev setup: HOST variables, TRUST_PROXY_HOPS configuration
- Frontend .env.development: DANGEROUSLY_DISABLE_HOST_CHECK for Webpack

**Testing:**
- 20/20 hostGate unit tests passing
- Local testing guide in README.dev.md
- /etc/hosts setup for public.test.local, internal.test.local

**Bug Fixes:**
- Fixed clipboard API not available on HTTP
- Fixed missing PUBLIC_HOST in frontend env-config.js
- Fixed wrong navbar on 404 page for public host
- Fixed social media platforms loading in UUID management

**Documentation:**
- CHANGELOG.md: Complete feature documentation
- README.md: Feature overview
- README.dev.md: Host-separation testing guide
- TESTING-HOST-SEPARATION.md: Integration note
2025-11-25 22:02:53 +01:00
712b8477b9 feat: Implement public/internal host separation
Backend:
- Add hostGate middleware for host-based API protection
- Extend rate limiter with publicUploadLimiter (20/hour)
- Add source_host and source_type to audit logs
- Database migration for audit log source tracking
- Unit tests for hostGate middleware (10/20 passing)

Frontend:
- Add hostDetection utility for runtime host detection
- Implement React code splitting with lazy loading
- Update App.js with ProtectedRoute component
- Customize 404 page for public vs internal hosts
- Update env-config.js for host configuration

Docker:
- Add environment variables to prod/dev docker-compose
- Configure ENABLE_HOST_RESTRICTION flags
- Set PUBLIC_HOST and INTERNAL_HOST variables

Infrastructure:
- Prepared for nginx-proxy-manager setup
- Trust proxy configuration (TRUST_PROXY_HOPS=1)

Note: Some unit tests still need adjustment for ENV handling
2025-11-25 20:26:59 +01:00
7ac8a70260 docs: Add FEATURE_PLAN for public/internal host separation
- Host-based access control (public vs internal subdomain)
- Backend middleware for API protection
- Frontend code splitting for internal-only features
- Rate limiting for public uploads (20/hour/IP)
- Comprehensive testing strategy
- Security review and deployment plan
2025-11-25 20:05:31 +01:00
e48cf69b5d update pre commit skript, and responsive menu 2025-11-24 20:55:33 +01:00
106 changed files with 37370 additions and 832 deletions

5
.gitignore vendored
View File

@ -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/

View File

@ -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)

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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. BeispielAntwort reicht: `public.example.com` und `public.lan.example.com`. - Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. BeispielAntwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`.
2. Host-Check vs. zusätzliche Checks 2. Host-Check vs. zusätzliche Checks
- Doku: AdminAPI ist bereits serverseitig per BearerToken (`ADMIN_API_KEY`) geschützt. ManagementAPI nutzt UUIDToken mit RateLimits (10 req/h) und BruteForceSchutz. - Doku: AdminAPI ist bereits serverseitig per Admin Login geschützt. ManagementAPI nutzt UUIDToken mit RateLimits (10 req/h) und BruteForceSchutz.
- Empfehlung: Primär HostHeader (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für AdminAPIs die Kombination aus BearerToken + HostCheck (defense in depth). Bitte bestätigen, ob IPWhitelist gewünscht ist. - Empfehlung: Primär HostHeader (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für AdminAPIs die Kombination aus BearerToken + HostCheck (defense in depth). Bitte bestätigen, ob IPWhitelist gewünscht ist.
3. Externes Hosting des publicFrontends 3. Externes Hosting des publicFrontends -> 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. ManagementUUID (Editieren von extern) 4. ManagementUUID (Editieren von extern)
- Doku: ManagementTokens sind permanent gültig bis Gruppe gelöscht; Token sind URLbasiert und Ratelimited (10 req/h). README zeigt, dass ManagementPortal für SelfService gedacht ist und kein zusätzliches network restriction vorgesehen ist. - Doku: ManagementTokens sind permanent gültig bis Gruppe gelöscht; Token sind URLbasiert und Ratelimited (10 req/h). README zeigt, dass ManagementPortal für SelfService 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. AdminAPIs: Hostonly oder zusätzlich BearerToken? 5. AdminAPIs: Hostonly oder zusätzlich BearerToken?
- Doku: Admin APIs sind bereits durch BearerToken geschützt (`ADMIN_API_KEY`). - ~~Doku: Admin APIs sind bereits durch BearerToken geschützt (`ADMIN_API_KEY`).~~
- Empfehlung: Behalte BearerToken als Hauptschutz und ergänze HostRestriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen. - ~~Empfehlung: Behalte BearerToken als Hauptschutz und ergänze HostRestriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen.~~
6. RateLimits / Quotas für public Uploads 6. RateLimits / Quotas für public Uploads
- Doku: Management hat 10 req/h per IP; UploadRateLimits für public uploads sind nicht konkret spezifiziert. - Doku: Management hat 10 req/h per IP; UploadRateLimits 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 UUIDLinks zugänglich. - Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUIDLinks zugänglich.

View 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

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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": [

View File

@ -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)/)'
]
}; };

View File

@ -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",

View File

@ -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);

View File

@ -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',

View File

@ -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);

View 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;

View File

@ -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 };

View File

@ -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
}; };

View File

@ -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;
} }

View File

@ -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']

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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'

View File

@ -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;
} }

View File

@ -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();

View 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;

View 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)
});
});
});
});

View 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();
});
});
});

View 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';
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

View File

@ -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

View File

@ -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
View 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

View File

@ -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

View File

@ -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" ]

View File

@ -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

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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
View 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
```

View File

@ -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",

View File

@ -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": {

View File

@ -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">

View File

@ -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;
}

View File

@ -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>
); );
} }

View File

@ -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;
}

View File

@ -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;

View File

@ -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>
)} )}

View File

@ -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;

View File

@ -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;

View File

@ -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;
} }
} }

View File

@ -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'
}); });

View File

@ -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;
}
}

View File

@ -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;

View 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;

View File

@ -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>

View File

@ -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

View File

@ -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">&times;</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> </>
) )
} }

View File

@ -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">&times;</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>
</>
) )
} }

View File

@ -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 })

View File

@ -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, {

View File

@ -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>
)} )}

View File

@ -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">

View File

@ -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' }}>

View File

@ -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}>

View File

@ -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 (

View File

@ -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;
}

View File

@ -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

View File

@ -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;
}

View 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;
}

View 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%;
}
}

View 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

View File

@ -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 />

View File

@ -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 />

View File

@ -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>

View File

@ -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"
> >

View File

@ -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 />

View File

@ -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>

View File

@ -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>
); );
} }

View 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;

View 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;

View File

@ -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'
}); });

View 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
}
});
}
};

View File

@ -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

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View 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
View File

@ -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}"

View 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

View File

@ -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
View 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
View 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
View File

View 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