Compare commits

..

61 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
b7acc01e90 Removed Navigation in user upload page 2025-11-24 20:38:33 +01:00
57ba3864e2 removed sqllite web browser 2025-11-24 20:18:00 +01:00
80aca79b32 fix: changed ADMIN_SESSION_COOKIE_SECURE=true with pre-commit script 2025-11-24 20:15:46 +01:00
b912670cab fix: enforce session cookie behavior in prod 2025-11-24 20:00:52 +01:00
7a14c239d4 fix: Update Swagger Grouping 2025-11-23 21:48:40 +01:00
6332b82c6a Feature Request: admin session security
- replace bearer auth with session+CSRF flow and add admin user directory

- update frontend moderation flow, force password change gate, and new CLI

- refresh changelog/docs/feature plan + ensure swagger dev experience
2025-11-23 21:18:42 +01:00
fb4b3b95a6 Feature Request: security & public fronted 2025-11-23 11:46:30 +01:00
6574ee0171 fix: Link in Groups bei leeren Gruppen falsch 2025-11-22 14:07:15 +01:00
98b3616dc4 Fix: Admin deletion log, CSV export revoked consents, consent filter UI
Backend Fixes:
- Admin deletions now create deletion_log entries (admin_moderation_deletion)
- Static mount for /previews added to serve preview images
- Admin groups endpoint supports consent filter parameter

Frontend Improvements:
- Replaced consent dropdown with checkbox UI (Workshop, Facebook, Instagram, TikTok)
- Checkboxes use OR logic for filtering
- Revoked consents excluded from filter counts
- Updated ModerationGroupsPage to send consents array to backend

Infrastructure:
- Simplified nginx.conf (proxy /api/* to backend, all else to frontend)
- Fixed docker-compose port mapping (5001:5000)

Tests: 11/11 passed 
2025-11-22 11:13:10 +01:00
7af14a162d fix(frontend): Fix syntax error and remove unused imports
- Fix extra closing brace in ModerationGroupsPage exportConsentData
- Remove unused adminRequest import from ModerationGroupImagesPage
- Remove unused ConsentBadges import from ModerationGroupsPage

 Build tested: npm run build successful (compiled with warnings)
2025-11-16 19:51:36 +01:00
6effded8bf feat(frontend): Add comprehensive error handling for admin API
Phase 2: User-Friendly Error Handling

 Error Handler Service:
- Created adminErrorHandler.js with handleAdminError()
- User-friendly SweetAlert2 dialogs for all error types:
  * 403 Unauthorized - Clear admin token instructions
  * 429 Rate Limit - Wait and retry message
  * 404 Not Found - Resource not found
  * 500 Server Error - Internal server error
  * Generic errors with context

 Integrated Error Handling in all Admin Components:
- ModerationGroupsPage.js (all 6 admin operations)
- ModerationGroupImagesPage.js (group loading)
- DeletionLogSection.js (log loading + statistics)
- ConsentCheckboxes.js (platform loading)

 Error Context Messages:
- "Gruppe laden"
- "Gruppe freigeben"
- "Gruppe löschen"
- "Bild löschen"
- "Consent-Export"
- "Plattformen laden"
- "Lösch-Log laden"
- "Statistiken laden"

 Benefits:
- Clear technical details for admins in error dialogs
- Context-specific error messages
- Consistent error handling across all admin features
- Better debugging with detailed 403 instructions
2025-11-16 18:56:21 +01:00
cb640576f4 feat(frontend): Migrate all API routes to new structure with admin auth
Phase 1: Route Structure & Admin Authentication

 Route Prefix Fixes:
- All routes now use consistent /api prefix
- Public: /groups/* → /api/groups/*
- Admin: /groups/*, /moderation/* → /api/admin/*
- Social Media: /api/social-media/* → /api/admin/social-media/*

 Admin API Authentication:
- Created adminApi.js service with Bearer Token helpers
  * adminFetch() - Base fetch with Authorization header
  * adminGet() - GET with auto error handling
  * adminRequest() - POST/PUT/PATCH/DELETE with JSON
  * adminDownload() - For Blob downloads (CSV exports)
- Added frontend/.env.example with REACT_APP_ADMIN_API_KEY
- All /api/admin/* calls now use admin helpers

 Updated Components:
- ModerationGroupsPage.js: All admin endpoints migrated
- ModerationGroupImagesPage.js: Group loading + image deletion
- PublicGroupImagesPage.js: Fixed public group route
- DeletionLogSection.js: Deletion log endpoints
- ConsentCheckboxes.js: Platform loading

⚠️ Next Steps:
- Add user-friendly 403 error handling
- Test all affected pages
- Configure REACT_APP_ADMIN_API_KEY in deployment
2025-11-16 18:39:40 +01:00
25324cb91f Merge feature/autogen-openapi: Complete API restructuring with tests
 Completed Features:
- Comprehensive test suite (45 tests, 100% passing)
- Admin API authentication (Bearer Token)
- Automatic OpenAPI generation from route mappings
- Complete API documentation
- Frontend migration guide

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

See CHANGELOG.md and frontend/MIGRATION-GUIDE.md for details.
2025-11-16 18:26:26 +01:00
7cb264820e docs: Correct migration guide - ALL routes changed, not just admin
Critical corrections to frontend/MIGRATION-GUIDE.md:
- Emphasize ALL API routes now have consistent /api prefix
- Old routes had inconsistent prefixes (some with /api, some without)
- List specific files with wrong routes that need fixing:
  * ModerationGroupsPage.js: /groups/* → /api/admin/groups/*
  * ModerationGroupImagesPage.js: /moderation/groups/* → /api/admin/groups/*
  * PublicGroupImagesPage.js: /groups/* → /api/groups/*
- Add 3-phase checklist: Route Prefixes → Authentication → Testing
- Provide grep commands to find ALL fetch/axios calls
- Make clear this affects the entire frontend, not just admin features

Migration effort estimate increased: 3-4 hours (route audit + auth)
2025-11-16 18:25:32 +01:00
36e7302677 docs: Improve frontend migration guide visibility and remove obsolete test files
- Add prominent migration guide reference in README.dev.md API section
- Remove backend/TESTING.md (info now in README.dev.md)
- Remove backend/test-openapi-paths.js (replaced by automated tests)
2025-11-16 18:21:07 +01:00
cdb2aa95e6 feat: Add comprehensive test suite and admin API authentication
🧪 Testing Infrastructure (45 tests, 100% passing)
- Implemented Jest + Supertest framework for automated testing
- Unit tests: 5 tests for auth middleware (100% coverage)
- Integration tests: 40 tests covering admin, consent, migration, upload APIs
- Test execution time: ~10 seconds for full suite
- Coverage: 26% statements, 15% branches (realistic start)
- In-memory SQLite database for isolated testing
- Singleton server pattern for fast test execution
- Automatic cleanup and teardown

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

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

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

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

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

Test Status:  45/45 passing (100%)
Backend:  Production ready
Frontend: ⚠️ Migration required (see MIGRATION-GUIDE.md)
2025-11-16 18:08:48 +01:00
8e8150331d docs: add autogen-openapi feature request and plan 2025-11-16 11:13:24 +01:00
f9b24332cd Added FEATURE_REQUESTS 2025-11-16 11:03:07 +01:00
89e35e7de6 fix: Use correct image ID when deleting images in preview mode
Changed ImageGalleryCard to pass itemId (image.id) instead of index
when deleting images in preview mode. This fixes 'Image not found' error
when attempting to delete individual images in ManagementPortalPage
and ModerationGroupImagesPage.

The index was being passed to the API, but the API expects the actual
database image ID.
2025-11-15 18:59:21 +01:00
560c15017b Merge feature/SocialMedia into main
Phase 1: Social Media Consent Management (Nov 9-10, 2025)
- Backend: Database migrations, API endpoints, validation
- Frontend: ConsentCheckboxes, ConsentBadges, moderation filters
- GDPR compliance and audit logging

Phase 2: Self-Service Management Portal (Nov 11-15, 2025)
- Backend: Management APIs, token system, security features
- Frontend: Management portal UI, component reuse
- Modular UI Architecture: 4 reusable components (-227 lines)

All features tested and documented. Ready for production.
2025-11-15 18:48:15 +01:00
ede45aafdd docs: Update README and CHANGELOG for Phase 2 completion
README.md:
- Updated Phase 2 completion date: Nov 11-14 → Nov 11-15
- Added new section: Modular UI Architecture (Nov 15)
- Listed all reusable components and their modes
- Added code reduction metrics (62% reduction, -227 net lines)
- Removed outdated 'email link' mention

CHANGELOG.md:
- Complete rewrite for feature/SocialMedia branch
- Added Phase 1: Social Media Consent Management (Nov 9-10)
  * Backend: Migrations, APIs, validation
  * Frontend: ConsentCheckboxes, ConsentBadges, filters
  * Testing results and GDPR compliance
- Added Phase 2 Backend: Management Portal (Nov 11)
  * Management APIs, security features, audit log
  * Rate limiting and brute-force protection
- Added Phase 2 Frontend: Management Portal UI (Nov 13-14)
  * ManagementPortalPage, component reuse
  * Upload success integration
- Added Phase 2 UI Refactoring (Nov 15)
  * 4 new modular components (686 lines)
  * Multi-mode support (upload/edit/moderate)
  * Code reduction metrics
  * UI consistency patterns
  * Bug fixes

All documentation now accurately reflects Nov 9-15 work.
2025-11-15 18:45:16 +01:00
a7d2d7d6aa docs: Add note about manual vs automated testing
- Clarified that Phase 1 & 2 tests were done manually
- Added section for outstanding automated tests
- Listed missing test types: Unit, Integration, E2E, Performance, Security
- Status: All features manually tested and functional, but automated test suite pending
2025-11-15 18:41:40 +01:00
25ef26534b docs: Clean up duplicate sections and update all checklists
- Removed duplicate 'Definition of Done' section
- Removed duplicate 'Implementierungs-Status' checklist items
- Updated Phase 1: All items marked as complete
- Updated Phase 2: Added comprehensive completion checklist
- Cleaned up outdated status markers
- All checklists now reflect actual completion status (Nov 9-15, 2025)
2025-11-15 18:40:01 +01:00
075e3ac980 docs: Update FEATURE_PLAN Phase 2 completion status
- Updated Phase 2 status: 100% complete (11-15 Nov 2025)
- Added comprehensive Phase 2 summary section
- Documented all 34 completed tasks (11 backend, 23 frontend)
- Added commits timeline (8 commits total)
- Added code metrics: +686 new lines, -227 net lines
- Documented modular components architecture
- Added technical achievements and best practices
- Updated Nice-to-Have checklist with completed items
- Updated task lists with [x] for completed items
- Status now reflects: Frontend management portal complete
2025-11-15 18:26:23 +01:00
bd7bdac000 refactor: Complete UI refactoring with modular components
- Refactored ManagementPortalPage, MultiUploadPage, ModerationGroupImagesPage
- Created reusable modular components with mode support:
  * ImageDescriptionManager (manage/moderate modes)
  * GroupMetadataEditor (edit/upload/moderate modes)
  * ConsentManager (edit/upload modes)
- Replaced Material-UI Buttons with HTML buttons + CSS classes
- Fixed image descriptions upload (preview ID to filename mapping)
- Reduced ModerationGroupImagesPage from 281 to 107 lines
- Updated ModerationGroupsPage and GroupsOverviewPage button styles
- All pages now use consistent Paper boxes with headings
- Inline Material-UI Alerts instead of SweetAlert2 popups (except destructive actions)
- Icons: 💾 save, ↩ discard, 🗑️ delete consistently used
2025-11-15 18:17:14 +01:00
4b9feec887 Refactor: Create modular component architecture for ManagementPortalPage
- Created new modular components:
  * ConsentManager: Manages workshop + social media consents with individual save
  * GroupMetadataEditor: Manages group metadata (title, description, name, year) with save
  * ImageDescriptionManager: Manages image descriptions with batch save
  * DeleteGroupButton: Standalone group deletion component

- Refactored ManagementPortalPage to use modular components:
  * Each component in Paper box with heading inside (not outside)
  * HTML buttons with CSS classes (btn btn-success, btn btn-secondary)
  * Inline feedback with Material-UI Alert instead of SweetAlert2 popups
  * Icons: 💾 save, ↩ discard, 🗑️ delete
  * Individual save/discard functionality per component

- Enhanced ConsentCheckboxes component:
  * Added children prop for flexible composition
  * Conditional heading for manage mode inside Paper box

- Fixed DescriptionInput:
  * Removed duplicate heading (now only in parent component)

- React state management improvements:
  * Deep copy pattern for nested objects/arrays
  * Sorted array comparison for order-insensitive change detection
  * Set-based comparison for detecting removed items
  * Initialization guard to prevent useEffect overwrites

- Bug fixes:
  * Fixed image reordering using existing /api/groups/:groupId/reorder route
  * Fixed edit mode toggle with unsaved changes warning
  * Fixed consent state updates with proper object references
  * Fixed uploadImageBatch signature to use object destructuring
  * Removed unnecessary /api/manage/:token/reorder route from backend

Next: Apply same modular pattern to MultiUploadPage and ModerationGroupImagesPage
2025-11-15 17:25:51 +01:00
324c46d735 feat(phase2): Complete Management Portal with reusable ConsentCheckboxes
Phase 2 Frontend completed (Tasks 12-17, 19-20) - 14. Nov 2025

Backend Enhancements:
- Enhanced PUT /api/manage/:token/consents to support creating new consents
- INSERT new consent row when restoring consent for platform not selected during upload
- Enables granting consents for previously unselected platforms

Frontend Refactoring (Code Deduplizierung):
- Extended ConsentCheckboxes component for both modes (upload & manage)
- Removed ~150 lines of duplicated consent UI code from ManagementPortalPage
- New mode prop: 'upload' (default) | 'manage'
- Dynamic hint texts and validation rules based on mode
- Workshop consent required only in upload mode

ManagementPortalPage Updates:
- Replaced custom consent UI with reusable ConsentCheckboxes component
- New state currentConsents tracks checkbox values
- New handler handleConsentChange() computes changes vs original
- Local change collection with batch save on button click
- Email link for social media post deletion (mailto workaround)
- Save/Discard buttons only visible when pending changes exist

ConsentBadges Fix:
- Now correctly displays only active (non-revoked) consents
- Updates properly after consent revocation

Documentation:
- Updated FEATURE_PLAN with Phase 2 Frontend completion status
- Added refactoring section documenting code deduplizierung
- Updated README with Management Portal features
- Documented email backend solution requirement (future work)

Results:
 100% consistent UI between upload and management
 Zero code duplication for consent handling
 ConsentBadges correctly filters revoked consents
 Backend supports granting new consents after upload
 Management link displayed on upload success page
 All manual tests passed

Tasks Completed:
- Task 12: Management Portal UI (/manage/:token)
- Task 13: Consent Management (revoke/restore)
- Task 14: Metadata Editor (title/description)
- Task 15: Image Management (add/delete)
- Task 16: Group Deletion (with confirmation)
- Task 17: Upload Success Page (management link)
- Task 19: Documentation updates
- Task 20: nginx routing configuration

Pending:
- Task 18: E2E Testing (formal test suite)
2025-11-14 14:38:03 +01:00
e065f2bbc4 wip(phase2): Task 17 - Management-Link in Upload-Erfolg & Rate-Limiter Anpassung
- Task 17: Management-Link im Upload-Erfolg angezeigt mit Copy-Button
- Widerruf-Dialoge überarbeitet: Klarstellung zu Scope & Kontakt für Social Media Posts
- Rate-Limiter für Dev-Umgebung erhöht (100/h statt 10/h)
- Mailto-Link Verhalten noch nicht final getestet (Browser vs. Mail-Client)

ACHTUNG: Noch nicht vollständig getestet! Mailto-Funktionalität muss in verschiedenen Browsern validiert werden.
2025-11-13 22:03:50 +01:00
483be4fcf7 Merge feature/SocialMedia: Phase 1 social media consent management complete
Phase 1 Features (GDPR-compliant):
 Mandatory workshop display consent
 Optional per-platform social media consents (Facebook, Instagram, TikTok)
 Consent badges and filtering in moderation panel
 CSV/JSON export for legal documentation
 Group ID tracking for consent withdrawal
 Automatic migration system fixed
 Validated with 72 production groups (all GDPR-compliant)

Implementation: 13 commits, 2 days (Nov 9-10, 2025)
Branch: feature/SocialMedia → main
Status: Production-ready after code review
2025-11-10 17:56:43 +01:00
176 changed files with 50138 additions and 3023 deletions

5
.gitignore vendored
View File

@ -9,6 +9,11 @@ node_modules/
.env
.env.local
# Telegram credentials
scripts/.env.telegram
scripts/node_modules/
scripts/package-lock.json
# IDE
.vscode/
.idea/

220
AUTHENTICATION.md Normal file
View File

@ -0,0 +1,220 @@
# API Authentication Guide
## Übersicht
Die API verwendet **zwei verschiedene Authentifizierungs-Mechanismen** für unterschiedliche Zugriffslevel:
### 1. Admin-Routes (Session + CSRF)
- **Zweck**: Geschützte Admin-Funktionen (Deletion Log, Cleanup, Moderation, Statistics)
- **Methode**: HTTP Session (Cookie) + CSRF-Token
- **Konfiguration**: `.env``ADMIN_SESSION_SECRET` (+ Admin-Benutzer in DB)
### 2. Management-Routes (UUID Token)
- **Zweck**: Self-Service Portal für Gruppen-Verwaltung
- **Methode**: UUID v4 Token in URL-Path
- **Quelle**: Automatisch generiert beim Upload, gespeichert in DB
---
## 1. Admin Authentication
### Setup
1. **Session Secret setzen**:
```env
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
```
> Standardmäßig setzt der Server in Production HTTPS-Only Cookies (`Secure`). Falls deine Installation **ohne HTTPS** hinter einem internen Netzwerk läuft, kannst du das Verhalten über `ADMIN_SESSION_COOKIE_SECURE=false` explizit deaktivieren. Verwende dies nur in vertrauenswürdigen Umgebungen und setze den Wert vorzugsweise per lokaler Compose-Override-Datei oder geheimen ENV-Variablen, damit das Repo weiterhin den sicheren Default `true` behält.
2. **Backend starten** Migration legt Tabelle `admin_users` an.
3. **Setup-Status prüfen**:
```bash
curl -c cookies.txt http://localhost:5000/auth/setup/status
```
4. **Initialen Admin anlegen** (nur wenn `needsSetup=true`):
```bash
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123!"}' \
http://localhost:5000/auth/setup/initial-admin
```
5. **Login für weitere Sessions**:
```bash
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123!"}' \
http://localhost:5000/auth/login
```
6. **CSRF Token abrufen** (für mutierende Requests):
```bash
curl -b cookies.txt http://localhost:5000/auth/csrf-token
```
### Verwendung
Alle `/api/admin/*`- und `/api/system/*`-Routen setzen voraus:
1. Browser sendet automatisch das Session-Cookie (`sid`).
2. Für POST/PUT/PATCH/DELETE muss der Header `X-CSRF-Token` gesetzt werden.
Beispiel:
```bash
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-b cookies.txt \
-d '{"approved":true}' \
http://localhost:5000/api/admin/groups/abc123/approve
```
### Geschützte Endpoints (Auszug)
| Endpoint | Method | Beschreibung |
|----------|--------|--------------|
| `/api/admin/deletion-log` | GET | Deletion Log Einträge |
| `/api/admin/deletion-log/csv` | GET | Deletion Log als CSV |
| `/api/admin/cleanup/run` | POST | Cleanup manuell starten |
| `/api/admin/cleanup/status` | GET | Cleanup Status |
| `/api/admin/rate-limiter/stats` | GET | Rate-Limiter Statistiken |
| `/api/admin/groups` | GET | Alle Gruppen (Moderation) |
| `/api/admin/groups/:id/approve` | PATCH | Gruppe freigeben |
| `/api/admin/groups/:id` | DELETE | Gruppe löschen |
| `/api/system/migration/*` | POST | Migrationswerkzeuge |
### Error Codes
| Status | Bedeutung |
|--------|-----------|
| `401` | Session fehlt oder ist abgelaufen |
| `403` | CSRF ungültig oder Benutzer hat keine Admin-Rolle |
| `419` | (optional) Session wurde invalidiert |
---
## 2. Management Authentication
### Setup
**Kein Setup nötig!** Token werden automatisch generiert.
### Funktionsweise
1. **Bei Upload** wird automatisch ein UUID v4 Token generiert
2. **Token wird gespeichert** in DB (Spalte: `management_token`)
3. **Token wird zurückgegeben** in der Upload-Response
4. **Nutzer erhält Link** wie: `https://example.com/manage/{token}`
### Verwendung
Token wird **im URL-Path** übergeben (nicht im Header):
```bash
# Token validieren und Daten laden
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
# Bilder hochladen
POST /api/manage/550e8400-e29b-41d4-a716-446655440000/images
# Gruppe löschen
DELETE /api/manage/550e8400-e29b-41d4-a716-446655440000
```
### Geschützte Endpoints
| Endpoint | Method | Beschreibung |
|----------|--------|--------------|
| `/api/manage/:token` | GET | Gruppen-Daten laden |
| `/api/manage/:token/consents` | PUT | Social Media Consents |
| `/api/manage/:token/metadata` | PUT | Metadaten bearbeiten |
| `/api/manage/:token/images` | POST | Bilder hinzufügen |
| `/api/manage/:token/images/:imageId` | DELETE | Bild löschen |
| `/api/manage/:token` | DELETE | Gruppe löschen |
### Sicherheits-Features
- **Token-Format Validierung**: Nur gültige UUID v4 Tokens
- **Rate Limiting**: Schutz vor Brute-Force
- **Audit Logging**: Alle Aktionen werden geloggt
- **DB-Check**: Token muss in DB existieren
### Error Codes
| Status | Bedeutung |
|--------|-----------|
| `404` | Token nicht gefunden oder Gruppe gelöscht |
| `429` | Rate Limit überschritten |
---
## Testing
### Unit Tests
```bash
npm test -- tests/unit/auth.test.js
```
### Integration Tests
```bash
# Admin Auth testen
npm test -- tests/api/admin-auth.test.js
# Alle API Tests
npm test
```
### Manuelles Testen
1. **Login**:
```bash
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"Secret123"}' \
http://localhost:5000/auth/login
```
2. **CSRF holen**:
```bash
CSRF=$(curl -sb cookies.txt http://localhost:5000/auth/csrf-token | jq -r '.csrfToken')
```
3. **Admin-Route aufrufen**:
```bash
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" http://localhost:5000/api/admin/deletion-log
# → 200 OK
```
4. **Ohne Session** (z. B. Cookies löschen) → Request liefert `403 SESSION_REQUIRED`.
---
## Production Checklist
- [ ] `ADMIN_SESSION_SECRET` sicher generieren (>= 32 Bytes random)
- [ ] `.env` nicht in Git committen (bereits in `.gitignore`)
- [ ] HTTPS verwenden (TLS/SSL) damit Cookies `Secure` gesetzt werden können (falls nicht möglich: `ADMIN_SESSION_COOKIE_SECURE=false` setzen nur in vertrauenswürdigen Netzen)
- [ ] Session-Store auf persistentem Volume ablegen
- [ ] Rate Limiting & Audit Logs überwachen
- [ ] Admin-Benutzerverwaltung (On-/Offboarding) dokumentieren
---
## Sicherheits-Hinweise
### Session-Secret Rotation
1. Wartungsfenster planen (alle Sessions werden invalidiert)
2. Neuen `ADMIN_SESSION_SECRET` generieren
3. `.env` aktualisieren und Backend neu starten
### Management-Token
- Token sind **permanent gültig** bis Gruppe gelöscht wird
- Bei Verdacht auf Leak: Gruppe löschen (löscht auch Token)
- Token-Format (UUID v4) macht Brute-Force unpraktisch
### Best Practices
- Keine Admin-Secrets im Frontend oder in Repos committen
- Admin-Session-Cookies nur über HTTPS ausliefern
- Rate-Limiting für beide Auth-Typen aktiv halten
- Audit-Logs regelmäßig auf Anomalien prüfen
- Session-Store-Backups schützen (enthalten Benutzer-IDs)

View File

@ -1,6 +1,423 @@
# Changelog
## [Unreleased] - Branch: feature/PreloadImage
## [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)
#### Backend
- ✅ **Server-Side Sessions + CSRF**: Replaced Bearer-token auth with HttpOnly session cookies backed by SQLite, added `requireAdminAuth` + `requireCsrf` middlewares, and exposed `GET /auth/csrf-token` for clients.
- ✅ **New Auth Lifecycle**: Added `GET /auth/setup/status`, `POST /auth/setup/initial-admin`, `POST /auth/login`, `POST /auth/logout`, `POST /auth/change-password`, and `POST /api/admin/users` to support onboarding, login, rotation, and creating additional admins.
- ✅ **Admin Directory**: Introduced `admin_users` table, repository, and `AdminAuthService` (hash/verify, forced password change flag, audit-friendly responses) plus Jest coverage for the new flows.
- ✅ **OpenAPI & Swagger Stability**: Regenerate spec on dev start only, ignore `docs/openapi.json` in nodemon watches, and expose Swagger UI reliably at `http://localhost:5001/api/docs/`.
#### Frontend
- ✅ **Admin Session Context**: New `AdminSessionProvider` manages setup/login state, CSRF persistence, and guards moderation routes via `AdminSessionGate`.
- ✅ **Force Password Change UX**: Added `ForcePasswordChangeForm`, change-password API helper, and conditional gate that blocks moderation access until the first login password is rotated.
- ✅ **Management UI Updates**: Moderation/management pages now assume cookie-based auth, automatically attach CSRF headers, and gracefully handle session expiry.
#### Tooling & Scripts
- ✅ **API-Driven CLI**: Replaced the legacy Node-only helper with `scripts/create_admin_user.sh`, which can bootstrap the first admin or log in via API to add additional admins from any Linux machine.
- ✅ **Docker & Docs Alignment**: Updated dev/prod compose files, Nginx configs, and `README*`/`AUTHENTICATION.md`/`frontend/MIGRATION-GUIDE.md` to describe the new security model and CLI workflow.
- ✅ **Feature Documentation**: Added `FeatureRequests/FEATURE_PLAN-security.md` + `FEATURE_TESTPLAN-security.md` outlining design, validation steps, and residual follow-ups.
---
## feature/SocialMedia
### 🧪 Comprehensive Test Suite & Admin API Security (November 16, 2025)
#### Testing Infrastructure
- ✅ **Jest + Supertest Framework**: 45 automated tests covering all API endpoints
- Unit tests: 5 tests for authentication middleware (100% coverage)
- Integration tests: 40 tests for API endpoints
- Test success rate: 100% (45/45 passing)
- Execution time: ~10 seconds for full suite
- ✅ **Test Organization**:
- `tests/unit/` - Unit tests (auth.test.js)
- `tests/api/` - Integration tests (admin, consent, migration, upload)
- `tests/setup.js` - Global configuration with singleton server pattern
- `tests/testServer.js` - Test server helper utilities
- ✅ **Test Environment**:
- In-memory SQLite database (`:memory:`) for isolation
- Temporary upload directories (`/tmp/test-image-uploader/`)
- Singleton server pattern for fast test execution
- Automatic cleanup after test runs
- `NODE_ENV=test` environment detection
- ✅ **Code Coverage**:
- Statements: 26% (above 20% threshold)
- Branches: 15%
- Functions: 16%
- Lines: 26%
#### Admin API Authentication
- ✅ **Bearer Token Security**: Protected all admin and dangerous system endpoints
- `requireAdminAuth` middleware for Bearer token validation
- Environment variable: `ADMIN_API_KEY` for token configuration
- Protected routes: All `/api/admin/*`, `/api/system/migration/migrate`, `/api/system/migration/rollback`
- HTTP responses: 403 for invalid/missing tokens, 500 if ADMIN_API_KEY not configured
- ✅ **Authentication Documentation**:
- Complete setup guide in `AUTHENTICATION.md`
- Example token generation commands (openssl, Node.js)
- curl and Postman usage examples
- Security best practices and production checklist
#### API Route Documentation
- ✅ **Single Source of Truth**: `backend/src/routes/routeMappings.js`
- Centralized route configuration for server and OpenAPI generation
- Comprehensive API overview in `backend/src/routes/README.md`
- Critical Express routing order documented and enforced
- ✅ **Route Order Fix**: Fixed Express route matching bug
- Problem: Generic routes (`/groups/:groupId`) matched before specific routes (`/groups/by-consent`)
- Solution: Mount consent router before admin router on `/api/admin` prefix
- Documentation: Added comments explaining why order matters
- ✅ **OpenAPI Auto-Generation**:
- Automatic spec generation on backend start (dev mode)
- Swagger UI available at `/api/docs/` in development
- Skip generation in test and production modes
#### Bug Fixes
- 🐛 Fixed: SQLite connection callback not properly awaited (caused test hangs)
- Wrapped `new sqlite3.Database()` in Promise for proper async/await
- 🐛 Fixed: Upload endpoint file validation checking `req.files.file` before `req.files` existence
- Added `!req.files` check before accessing `.file` property
- 🐛 Fixed: Test uploads failing with EACCES permission denied
- Use `/tmp/` directory in test mode instead of `data/images/`
- Dynamic path handling with `path.isAbsolute()` check
- 🐛 Fixed: Express route order causing consent endpoints to return 404
- Reordered routers: consent before admin in routeMappings.js
#### Frontend Impact
**⚠️ Action Required**: Frontend needs updates for new authentication system
1. **Admin API Calls**: Add Bearer token header
```javascript
headers: {
'Authorization': `Bearer ${ADMIN_API_KEY}`
}
```
2. **Route Verification**: Check all API paths against `routeMappings.js`
- Consent routes: `/api/admin/groups/by-consent`, `/api/admin/consents/export`
- Migration routes: `/api/system/migration/*` (not `/api/migration/*`)
3. **Error Handling**: Handle 403 responses for missing/invalid authentication
4. **Environment Configuration**: Add `REACT_APP_ADMIN_API_KEY` to frontend `.env`
#### Technical Details
- **Backend Changes**:
- New files: `middlewares/auth.js`, `tests/` directory structure
- Modified files: All admin routes now protected, upload.js validation improved
- Database: Promisified SQLite connection in DatabaseManager.js
- Constants: Test-mode path handling in constants.js
- **Configuration Files**:
- `jest.config.js`: Test configuration with coverage thresholds
- `.env.example`: Added ADMIN_API_KEY documentation
- `package.json`: Added Jest and Supertest dependencies
---
### 🎨 Modular UI Architecture (November 15, 2025)
#### Features
- ✅ **Reusable Component System**: Created modular components for all pages
- `ConsentManager.js` (263 lines): Workshop + Social Media consents with edit/upload modes
- `GroupMetadataEditor.js` (146 lines): Metadata editing with edit/upload/moderate modes
- `ImageDescriptionManager.js` (175 lines): Batch image descriptions with manage/moderate modes
- `DeleteGroupButton.js` (102 lines): Standalone group deletion component
- ✅ **Multi-Mode Support**: Components adapt behavior based on context
- `mode="upload"`: External state, no save buttons (MultiUploadPage)
- `mode="edit"`: Management API endpoints (ManagementPortalPage)
- `mode="moderate"`: Admin API endpoints (ModerationGroupImagesPage)
- ✅ **Code Reduction**: Massive reduction in code duplication
- ManagementPortalPage: 1000→400 lines (-60%)
- ModerationGroupImagesPage: 281→107 lines (-62%)
- MultiUploadPage: Refactored to use modular components
- Net result: +288 lines added, -515 lines removed = **-227 lines total**
#### UI Consistency
- 🎨 **Design System**: Established consistent patterns across all pages
- Paper boxes with headings inside (not outside)
- HTML `<button>` with CSS classes instead of Material-UI Button
- Material-UI Alert for inline feedback (SweetAlert2 only for destructive actions)
- Icons: 💾 save, ↩ discard, 🗑️ delete, 📥 download
- Individual save/discard per component section
#### Bug Fixes
- <20> Fixed: Image descriptions not saving during upload (preview ID → filename mapping)
- 🐛 Fixed: FilterListIcon import missing in ModerationGroupsPage
- 🐛 Fixed: Button styles inconsistent across pages
#### Technical Details
- **Frontend Changes**:
- New files: 4 modular components (686 lines)
- Refactored files: 7 pages with consistent patterns
- State management: Deep copy pattern, JSON comparison, set-based comparison
- API integration: Mode-based endpoint selection
---
### 🔑 Self-Service Management Portal (November 11-14, 2025)
#### Backend Features (Phase 2 Backend - Nov 11)
- ✅ **Management Token System**: UUID v4 token generation and validation
- Tokens stored in `groups.management_token` column
- Token-based authentication for all management operations
- Format validation (UUID v4 regex)
- ✅ **Management APIs**: Complete self-service functionality
- `GET /api/manage/:token` - Load group data
- `PUT /api/manage/:token/consents` - Revoke/restore consents
- `PUT /api/manage/:token/metadata` - Edit title/description
- `PUT /api/manage/:token/images/descriptions` - Batch update descriptions
- `POST /api/manage/:token/images` - Add images (max 50 per group)
- `DELETE /api/manage/:token/images/:imageId` - Delete single image
- `DELETE /api/manage/:token` - Delete entire group
- ✅ **Security Features**:
- Rate limiting: 10 requests/hour per IP (in-memory)
- Brute-force protection: 20 failed attempts → 24h IP ban
- Management audit log: All actions tracked in `management_audit_log` table
- Token masking: Only first 8 characters logged
- ✅ **Database Migration 007**: Management audit log table
- Tracks: action, success, error_message, ip_address, user_agent
- Indexes for performance: group_id, action, ip_address, created_at
#### Frontend Features (Phase 2 Frontend - Nov 13-14)
- ✅ **Management Portal Page**: Full-featured user interface at `/manage/:token`
- Token validation with error handling
- Consent management UI (revoke/restore)
- Metadata editing UI
- Image upload/delete UI
- Group deletion UI (with confirmation)
- ✅ **Component Reuse**: ConsentCheckboxes with mode support
- `mode="upload"`: Upload page behavior
- `mode="manage"`: Management portal behavior
- Eliminates ~150 lines of duplicated code
- ✅ **Upload Success Integration**: Management link prominently displayed
- Copy-to-clipboard functionality
- Security warning about safe storage
- Email link for social media post deletion requests
---
### 🔐 Social Media Consent Management (November 9-10, 2025)
#### Backend Features (Phase 1 Backend - Nov 9)
- ✅ **Database Migrations**:
- Migration 005: Added consent fields to `groups` table
* `display_in_workshop` (BOOLEAN, NOT NULL, default 0)
* `consent_timestamp` (DATETIME)
* `management_token` (TEXT, UNIQUE) - for Phase 2
- Migration 006: Social media platform system
* `social_media_platforms` table (configurable platforms)
* `group_social_media_consents` table (per-group, per-platform consents)
* Revocation tracking: `revoked`, `revoked_timestamp` columns
- GDPR-compliant: Old groups keep `display_in_workshop = 0` (no automatic consent)
- ✅ **API Endpoints**:
- `GET /api/social-media/platforms` - List active platforms (Facebook, Instagram, TikTok)
- `POST /api/groups/:groupId/consents` - Save consents (batch operation)
- `GET /api/groups/:groupId/consents` - Load consent status
- `GET /api/admin/groups/by-consent` - Filter groups by consent (all, workshop, platform-specific)
- `GET /api/admin/consents/export` - Export consent data (CSV/JSON format)
- ✅ **Upload Validation**: 400 error if `display_in_workshop` not set to true
- ✅ **Repositories**:
- `SocialMediaRepository.js`: Platform & consent management
- Extended `GroupRepository.js`: Consent filtering queries
#### Frontend Features (Phase 1 Frontend - Nov 10)
- ✅ **ConsentCheckboxes Component**: GDPR-compliant consent UI
- Workshop consent (mandatory, cannot upload without)
- Social media consents (optional, per-platform checkboxes)
- Informative tooltips explaining usage
- Legal notice about moderation and withdrawal rights
- ✅ **ConsentBadges Component**: Visual consent status indicators
- Icons: 🏭 Workshop, 📱 Facebook, 📷 Instagram, 🎵 TikTok
- Tooltips with consent details and timestamps
- Filtering support for revoked consents
- ✅ **Moderation Panel Updates**:
- Consent filter dropdown (All, Workshop-only, per-platform)
- Export button for CSV/JSON download
- Consent badges on each group card
- In-memory filtering (loads all groups, filters client-side)
- ✅ **Upload Success Dialog**: Group ID display for consent withdrawal reference
#### Testing Results (Nov 10)
- ✅ Upload with/without workshop consent
- ✅ Social media consent persistence
- ✅ Filter functionality (All: 76, Workshop: 74, Facebook: 2)
- ✅ CSV export with proper formatting
- ✅ Badge icons and tooltips
- ✅ Migration 005 & 006 auto-applied on startup
- ✅ GDPR validation: 72 old groups with display_in_workshop = 0
---
## Preload Image
### 🚀 Slideshow Optimization (November 2025)
@ -37,7 +454,7 @@
---
## [Unreleased] - Branch: feature/DeleteUnprovedGroups
## Delete Unproved Groups
### ✨ Automatic Cleanup Feature (November 2025)
@ -104,7 +521,7 @@
---
## [Unreleased] - Branch: feature/ImageDescription
## Image Description
### ✨ Image Descriptions Feature (November 2025)
@ -178,7 +595,7 @@
---
## [Unreleased] - Branch: upgrade/deps-react-node-20251028
## Upgrade Deps: React & Node (October 2025)
### 🎯 Major Framework Upgrades (October 2025)

View File

@ -0,0 +1,23 @@
# E-Mail-Benachrichtigungen
**Status**: ⏳ Geplant
- Backend: E-Mail-Service (nodemailer)
- Upload-Bestätigung mit Management-Link
- Optional: E-Mail-Adresse beim Upload abfragen
---
# 📚 Referenzen
- [DSGVO Art. 7 - Bedingungen für die Einwilligung](https://dsgvo-gesetz.de/art-7-dsgvo/)
- [Material-UI Checkbox Documentation](https://mui.com/material-ui/react-checkbox/)
- [SQLite Foreign Key Support](https://www.sqlite.org/foreignkeys.html)
- [UUID v4 Best Practices](https://www.rfc-editor.org/rfc/rfc4122)
---
**Erstellt am**: 15. November 2025
**Letzte Aktualisierung**: 15. November 2025, 18:20 Uhr
**Status**: ✅ Phase 1: 100% komplett | ✅ Phase 2 Backend: 100% komplett | ✅ Phase 2 Frontend: 100% komplett
**Production-Ready**: Ja (alle Features implementiert und getestet)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,195 @@
# Feature Plan: Autogenerierte OpenAPI / Swagger Spec + API Restructuring
**Branch:** `feature/autogen-openapi`
**Datum:** 16. November 2025
**Status:** ✅ Complete - Auto-generation active, Single Source of Truth established
## 🎯 Hauptziele
1. ✅ **OpenAPI Auto-Generation:** Swagger Spec wird automatisch aus Route-Definitionen generiert
2. ✅ **Konsistente API-Struktur:** Klare, REST-konforme API-Organisation für einfache KI-Navigation
3. ✅ **Single Source of Truth:** `routeMappings.js` als zentrale Route-Konfiguration
4. ✅ **Developer Experience:** Swagger UI unter `/api/docs/` (dev-only)
5. ✅ **Test Coverage:** 45 automatisierte Tests, 100% passing
6. ✅ **API Security:** Bearer Token Authentication für Admin-Endpoints
---
## 📊 API-Struktur (Ziel)
### Design-Prinzipien
- **Prefix = Zugriffsebene:** Struktur basiert auf Authentifizierung/Autorisierung
- **REST-konform:** Standard HTTP Methoden (GET, POST, PUT, PATCH, DELETE)
- **KI-freundlich:** Klare Hierarchie, vorhersagbare Patterns
- **Konsistent:** Alle Routen folgen dem gleichen Muster
### Routing-Schema
```
/api/upload (öffentlich - Upload-Funktionen)
/api/groups (öffentlich - Slideshow-Anzeige)
/api/manage/:token/* (token-basiert - User-Verwaltung)
/api/admin/* (geschützt - Moderation)
/api/system/* (intern - Wartung)
```
### Detaillierte Endpunkte
#### 📤 Public API
```
POST /api/upload - Single file upload
POST /api/upload/batch - Batch upload
GET /api/groups - List approved slideshows
GET /api/groups/:groupId - View specific slideshow
```
#### 🔑 Management API
Token-basierter Zugriff für Slideshow-Ersteller:
```
GET /api/manage/:token - Get slideshow info
PUT /api/manage/:token/consents - Update consents
PUT /api/manage/:token/metadata - Update metadata
PUT /api/manage/:token/images/descriptions - Update image descriptions
POST /api/manage/:token/images - Add images
DELETE /api/manage/:token/images/:imageId - Delete image
DELETE /api/manage/:token - Delete slideshow
```
#### 👮 Admin API
Geschützte Moderation- und Management-Funktionen:
```
# Moderation
GET /api/admin/moderation/groups - List pending slideshows
GET /api/admin/moderation/groups/:id - Get slideshow details
PATCH /api/admin/groups/:id/approve - Approve slideshow
PATCH /api/admin/groups/:id - Edit slideshow
DELETE /api/admin/groups/:id/images/:imageId - Delete single image
PATCH /api/admin/groups/:id/images/batch-description
PUT /api/admin/groups/:id/reorder - Reorder images
# Logs & Monitoring
GET /api/admin/deletion-log - Recent deletions
GET /api/admin/deletion-log/stats - Deletion statistics
GET /api/admin/management-audit - Audit log
GET /api/admin/rate-limiter/stats - Rate limiter stats
# Cleanup
POST /api/admin/cleanup/trigger - Trigger cleanup
GET /api/admin/cleanup/preview - Preview cleanup targets
# Consents & Social Media
GET /api/admin/consents/export - Export consents (CSV)
GET /api/admin/social-media/platforms - List platforms
```
#### ⚙️ System API
Interne System-Operationen:
```
GET /api/system/migration/status - Migration status
POST /api/system/migration/migrate - Run migration
POST /api/system/migration/rollback - Rollback migration
GET /api/system/migration/health - Health check
```
---
## 🔧 Technische Implementierung
### Komponenten
- **swagger-autogen** (v6.2.8): OpenAPI 3.0 Generation
- **swagger-ui-express** (v4.6.3): Interactive API docs
- **Custom Generator:** `src/generate-openapi.js`
### Generator-Logik
```javascript
// Pro Router-Datei einzeln scannen + Mount-Prefix anwenden
for each routerMapping {
swaggerAutogen(tempFile, [routeFile], { basePath: prefix })
merge paths with prefix into final spec
}
```
### Single Source of Truth
1. **Router-Files (`src/routes/*.js`)**: Enthalten nur relative Pfade
2. **Mount-Konfiguration (`src/routes/index.js`)**: Definiert Prefixes
3. **OpenAPI Generation:** `generate-openapi.js` liest beide und merged
---
## 📚 Für KI-Nutzung
### API-Hierarchie verstehen
```
/api/* ← Alle API-Endpoints
├─ /upload, /groups ← Öffentlich
├─ /manage/:token/* ← Token-basiert
├─ /admin/* ← Geschützt
└─ /system/* ← Intern
```
### Neue Route hinzufügen
```bash
# 1. Route in passender Datei hinzufügen (z.B. admin.js)
router.get('/new-endpoint', ...)
# 2. In routeMappings.js registrieren (falls neue Datei)
{ router: 'newRoute', prefix: '/api/admin', file: 'newRoute.js' }
# 3. OpenAPI wird automatisch beim Backend-Start generiert
npm run dev
# 4. Tests schreiben: tests/api/newRoute.test.js
npm test
# 5. Swagger UI: http://localhost:5001/api/docs/
```
---
## ✅ Implementierungsstatus (November 16, 2025)
### Completed Features
**Single Source of Truth**: `routeMappings.js` als zentrale Route-Konfiguration
**Auto-Generation**: OpenAPI-Spec automatisch beim Backend-Start
**Authentication**: Bearer Token für Admin-Endpoints
**Test Suite**: 45 automatisierte Tests (100% passing)
**Documentation**: `routes/README.md` + `AUTHENTICATION.md`
**Route Order Fix**: Express routing order documented & fixed
### Known Issues (Resolved)
**Express Route Order**: Consent router now mounted before admin router
**Test Permissions**: Tests use `/tmp/` for uploads
**SQLite Async**: Connection properly promisified
---
## ⏱️ Aufwandsschätzung (Final)
| Phase | Zeit | Status |
|-------|------|--------|
| MVP OpenAPI Generation | 2h | ✅ Done |
| API Restructuring | 8h | ✅ Done |
| Authentication System | 4h | ✅ Done |
| Test Suite | 6h | ✅ Done |
| Documentation | 2h | ✅ Done |
| **Total** | **22h** | **100%** |
---
## 🚀 Frontend Migration Guide
**Required Changes:**
1. **Add Bearer Token**: All `/api/admin/*` calls need `Authorization: Bearer <token>` header
2. **Verify Paths**: Check against `routeMappings.js` (consent: `/api/admin/groups/by-consent`)
3. **Handle 403**: Add error handling for missing authentication
4. **Environment**: Add `REACT_APP_ADMIN_API_KEY` to `.env`
**See `AUTHENTICATION.md` for complete setup guide**
---
**Erstellt:** 16. November 2025
**Aktualisiert:** 16. November 2025
**Status:** ✅ Production Ready

View File

@ -10,14 +10,14 @@
## 🎯 Funktionale Anforderungen
### Must-Have
- [ ] **Automatische Löschung**: Gruppen mit `approved = false` werden nach 7 Tagen ab Upload-Zeitpunkt gelöscht
- [ ] **Vollständige Löschung**: Datenbank-Einträge, Originalbilder und Preview-Bilder werden entfernt
- [ ] **Cron-Job**: Tägliche Ausführung um 10:00 Uhr morgens
- [ ] **Deletion Log**: Protokollierung gelöschter Gruppen in eigener Datenbanktabelle
- [ ] **Anonymisierung**: Keine personenbezogenen Daten (Titel, Name, Beschreibung) im Log
- [ ] **Countdown-Anzeige**: In ModerationPage wird Restzeit bis zur Löschung angezeigt
- [ ] **Admin-Übersicht**: Geschützter Bereich in ModerationPage für Lösch-Historie
- [ ] **Freigabe-Schutz**: Freigegebene Gruppen (`approved = true`) werden niemals automatisch gelöscht
- [x] **Automatische Löschung**: Gruppen mit `approved = false` werden nach 7 Tagen ab Upload-Zeitpunkt gelöscht
- [x] **Vollständige Löschung**: Datenbank-Einträge, Originalbilder und Preview-Bilder werden entfernt
- [x] **Cron-Job**: Tägliche Ausführung um 10:00 Uhr morgens
- [x] **Deletion Log**: Protokollierung gelöschter Gruppen in eigener Datenbanktabelle
- [x] **Anonymisierung**: Keine personenbezogenen Daten (Titel, Name, Beschreibung) im Log
- [x] **Countdown-Anzeige**: In ModerationPage wird Restzeit bis zur Löschung angezeigt
- [x] **Admin-Übersicht**: Geschützter Bereich in ModerationPage für Lösch-Historie
- [x] **Freigabe-Schutz**: Freigegebene Gruppen (`approved = true`) werden niemals automatisch gelöscht
### Nice-to-Have
- [ ] **Manuelle Verzögerung**: Admin kann Löschfrist verlängern (z.B. um weitere 7 Tage)

View File

@ -506,94 +506,94 @@ export const uploadImageBatch = async (files, metadata, descriptions = []) => {
### Manuelle Tests
- [ ] Upload mehrerer Bilder mit verschiedenen Beschreibungen
- [ ] Upload ohne Beschreibungen
- [ ] Bearbeiten bestehender Gruppen
- [ ] Slideshow mit Beschreibungen testen
- [x] Upload mehrerer Bilder mit verschiedenen Beschreibungen
- [x] Upload ohne Beschreibungen
- [x] Bearbeiten bestehender Gruppen
- [x] Slideshow mit Beschreibungen testen
- [ ] Mobile-Ansicht testen
- [ ] Performance mit vielen Bildern testen
- [x] Performance mit vielen Bildern testen
---
## 📝 Implementation TODO
### Phase 1: Backend Foundation ✅
- [ ] **Task 1.1:** Datenbank-Migration erstellen
- [ ] `004_add_image_description.sql` erstellen
- [ ] Migration in `DatabaseManager.js` registrieren
- [ ] Lokale DB testen
- [x] **Task 1.1:** Datenbank-Migration erstellen
- [x] `004_add_image_description.sql` erstellen
- [x] Migration in `DatabaseManager.js` registrieren
- [ x Lokale DB testen
- [ ] **Task 1.2:** Repository-Layer erweitern
- [ ] `updateImageDescription()` in `GroupRepository.js`
- [ ] `updateBatchImageDescriptions()` in `GroupRepository.js`
- [ ] `getImagesByGroupId()` erweitern für `image_description`
- [x] **Task 1.2:** Repository-Layer erweitern
- [x] `updateImageDescription()` in `GroupRepository.js`
- [x] `updateBatchImageDescriptions()` in `GroupRepository.js`
- [x] `getImagesByGroupId()` erweitern für `image_description`
- [ ] **Task 1.3:** API-Routes implementieren
- [ ] `PATCH /groups/:groupId/images/:imageId` in `routes/groups.js`
- [ ] `PATCH /groups/:groupId/images/batch-description` in `routes/groups.js`
- [ ] Validierung hinzufügen (max 200 Zeichen)
- [ ] GET Routes erweitern (image_description returnen)
- [x] **Task 1.3:** API-Routes implementieren
- [x] `PATCH /groups/:groupId/images/:imageId` in `routes/groups.js`
- [x] `PATCH /groups/:groupId/images/batch-description` in `routes/groups.js`
- [x] Validierung hinzufügen (max 200 Zeichen)
- [x] GET Routes erweitern (image_description returnen)
- [ ] **Task 1.4:** Upload-Route erweitern
- [ ] `batchUpload.js` Route akzeptiert `descriptions` Parameter
- [ ] Speichere Beschreibungen beim Upload
- [ ] Backward-Kompatibilität testen
- [x] **Task 1.4:** Upload-Route erweitern
- [x] `batchUpload.js` Route akzeptiert `descriptions` Parameter
- [x] Speichere Beschreibungen beim Upload
- [x] Backward-Kompatibilität testen
### Phase 2: Frontend Core Components ✅
- [ ] **Task 2.1:** ImageGalleryCard.js anpassen
- [ ] "Sort" Button durch "Edit" Button ersetzen
- [ ] Edit-Modus UI implementieren (Textarea)
- [ ] Props hinzufügen: `isEditMode`, `onEditMode`, `imageDescription`, `onDescriptionChange`
- [ ] Zeichenzähler implementieren
- [ ] Validierung (max 200 Zeichen)
- [x] **Task 2.1:** ImageGalleryCard.js anpassen
- [x] "Sort" Button durch "Edit" Button ersetzen
- [x] Edit-Modus UI implementieren (Textarea)
- [x] Props hinzufügen: `isEditMode`, `onEditMode`, `imageDescription`, `onDescriptionChange`
- [x] Zeichenzähler implementieren
- [x] Validierung (max 200 Zeichen)
- [ ] **Task 2.2:** ImageGallery.js erweitern
- [ ] Neue Props durchreichen
- [ ] Edit-Modus State-Management
- [x] **Task 2.2:** ImageGallery.js erweitern
- [x] Neue Props durchreichen
- [x] Edit-Modus State-Management
- [ ] **Task 2.3:** CSS-Styles hinzufügen
- [ ] `ImageGallery.css` erweitern
- [ ] Textarea-Styles
- [ ] Zeichenzähler-Styles
- [ ] Edit-Button-Styles
- [ ] Mobile-Optimierung
- [x] **Task 2.3:** CSS-Styles hinzufügen
- [x] `ImageGallery.css` erweitern
- [x] Textarea-Styles
- [x] Zeichenzähler-Styles
- [x] Edit-Button-Styles
- [x] Mobile-Optimierung
### Phase 3: Upload Flow Integration ✅
- [ ] **Task 3.1:** MultiUploadPage.js erweitern
- [ ] State für Edit-Modus hinzufügen
- [ ] State für Beschreibungen hinzufügen
- [ ] Handler für Edit-Modus implementieren
- [ ] Handler für Beschreibungsänderungen implementieren
- [ ] Upload-Logik erweitern (Beschreibungen mitschicken)
- [ ] Edit-Mode Toggle UI hinzufügen
- [x] **Task 3.1:** MultiUploadPage.js erweitern
- [x] State für Edit-Modus hinzufügen
- [x] State für Beschreibungen hinzufügen
- [x] Handler für Edit-Modus implementieren
- [x] Handler für Beschreibungsänderungen implementieren
- [x] Upload-Logik erweitern (Beschreibungen mitschicken)
- [x] Edit-Mode Toggle UI hinzufügen
- [ ] **Task 3.2:** batchUpload.js erweitern
- [ ] Funktionssignatur anpassen (descriptions Parameter)
- [ ] FormData um Beschreibungen erweitern
- [ ] Error-Handling
- [x] **Task 3.2:** batchUpload.js erweitern
- [x] Funktionssignatur anpassen (descriptions Parameter)
- [x] FormData um Beschreibungen erweitern
- [x] Error-Handling
### Phase 4: Moderation Integration ✅
- [ ] **Task 4.1:** ModerationGroupImagesPage.js erweitern
- [ ] State für Edit-Modus hinzufügen
- [ ] State für Beschreibungen hinzufügen
- [ ] `loadGroup()` erweitern (Beschreibungen laden)
- [ ] Handler für Beschreibungsänderungen implementieren
- [ ] `handleSaveDescriptions()` implementieren
- [ ] Edit-Mode Toggle UI hinzufügen
- [ ] Optimistic Updates
- [x] **Task 4.1:** ModerationGroupImagesPage.js erweitern
- [x] State für Edit-Modus hinzufügen
- [x] State für Beschreibungen hinzufügen
- [x] `loadGroup()` erweitern (Beschreibungen laden)
- [x] Handler für Beschreibungsänderungen implementieren
- [x] `handleSaveDescriptions()` implementieren
- [x] Edit-Mode Toggle UI hinzufügen
- [x] Optimistic Updates
### Phase 5: Slideshow Integration ✅
- [ ] **Task 5.1:** SlideshowPage.js erweitern
- [ ] Beschreibungs-Anzeige UI implementieren
- [ ] CSS für Slideshow-Beschreibung
- [ ] Responsive Design
- [ ] Conditional Rendering (nur wenn Beschreibung vorhanden)
- [x] **Task 5.1:** SlideshowPage.js erweitern
- [x] Beschreibungs-Anzeige UI implementieren
- [x] CSS für Slideshow-Beschreibung
- [x] Responsive Design
- [x] Conditional Rendering (nur wenn Beschreibung vorhanden)
- [ ] **Task 5.2:** Slideshow-Styles
- [ ] `.slideshow-description` CSS
- [ ] Overlay-Styling
- [ ] Animation (optional)
- [ ] Mobile-Ansicht
- [x] **Task 5.2:** Slideshow-Styles
- [x] `.slideshow-description` CSS
- [x] Overlay-Styling
- [x] Animation (optional)
- [x] Mobile-Ansicht
### Phase 6: Groups Overview Integration ✅
- [ ] **Task 6.1:** GroupsOverviewPage.js erweitern

View File

@ -0,0 +1,109 @@
# Feature Plan: Server-seitige Sessions für Admin-API
## Kontext
- Ziel: Admin-API auf serverseitige Sessions mit CSRF-Schutz umstellen, Secrets ausschließlich backendseitig halten.
- Initialer Admin wird über einen Setup-Wizard in der Admin-UI angelegt; weitere Admins werden in einer neuen `admin_users`-Tabelle verwaltet.
- Session-Cookies (HttpOnly, Secure, SameSite=Strict) und SQLite-basierter Session-Store.
## Annahmen & Randbedingungen
1. Backend nutzt weiterhin SQLite; Session-Daten liegen in separater Datei (`sessions.sqlite`).
2. Session-Secret (`ADMIN_SESSION_SECRET`) bleibt als ENV-Variable im Backend.
3. Frontend authentifiziert sich ausschließlich via Session-Cookie + `X-CSRF-Token`; keine Bearer-Tokens im Browser.
4. Initialer Admin wird per UI-Wizard erstellt; falls Wizard nicht verfügbar ist, gibt es ein Fallback-CLI/Script.
5. `AUTHENTICATION.md` und `frontend/MIGRATION-GUIDE.md` sind maßgebliche Dokumente für Auth-Flow.
## Aufgaben-Backlog
- [x] **Session Store & Konfiguration**
- `express-session` + `connect-sqlite3` installieren und konfigurieren.
- Session-Datei z.B. unter `backend/src/data/sessions.sqlite` speichern.
- Cookie-Flags gemäß Prod/Dev setzen.
- [x] **Admin User Datenbank**
- Migration / Schema für `admin_users` inkl. Passwort-Hash (bcrypt) und Meta-Feldern.
- Seed-/Wizard-Mechanismus für ersten Admin.
- [x] **Login / Logout Endpoints**
- `POST /auth/login` prüft Credentials gegen DB.
- `POST /auth/logout` zerstört Session + Cookie.
- Bei Login `req.session.user` + `req.session.csrfToken` setzen.
- [x] **CSRF Token & Middleware**
- `GET /auth/csrf-token` (nur authentifizierte Sessions).
- Middleware `requireCsrf` für mutierende Admin-/System-Routen.
- [x] **Initial Admin Setup Flow (Backend)**
- `GET /auth/setup/status` liefert `{ needsSetup: boolean }` basierend auf Admin-Anzahl.
- `POST /auth/setup/initial-admin` erlaubt das Anlegen des ersten Admins (nur wenn `needsSetup` true).
- UI-Wizard ruft Status ab, zeigt Formular und loggt Admin optional direkt ein.
## Endpoint-Konzept
- `POST /auth/setup/initial-admin`
- Body: `{ username, password }` (optional `passwordConfirm` auf UI-Seite validieren).
- Backend: prüft, dass keine aktiven Admins existieren, erstellt Nutzer (bcrypt Hash) und markiert Session als eingeloggt.
- Response: `{ success: true, csrfToken }` und setzt Session-Cookie.
- `GET /auth/setup/status`
- Response: `{ needsSetup: true|false }` plus optional `hasSession: boolean`.
- `POST /auth/login`
- Body: `{ username, password }`.
- Checks: User aktiv, Passwort korrekt (bcrypt.compare), optional Rate-Limit.
- Side-effects: `req.session.user = { id, username, role }`, `req.session.csrfToken = randomHex(32)`.
- Response: `{ success: true, csrfToken }` (Cookie kommt automatisch).
- `POST /auth/logout`
- Destroys session, clears cookie, returns 204/200.
- `GET /auth/csrf-token`
- Requires valid session, returns `{ csrfToken }` (regenerates when missing or `?refresh=true`).
- Middleware `requireAdminSession`
- Prüft `req.session.user?.role === 'admin'` und optional `is_active` Flag.
- Antwortet mit `403` + `{ reason: 'SESSION_REQUIRED' }` wenn nicht vorhanden.
- Middleware `requireCsrf`
- Gilt für `POST/PUT/PATCH/DELETE` auf `/api/admin/*` & `/api/system/*`.
- Erwartet Header `x-csrf-token`; vergleicht mit `req.session.csrfToken`.
- Bei Fehler: `403` + `{ reason: 'CSRF_INVALID' }`.
- Frontend-Fluss
- Nach Login/Setup: speichert gelieferten Token im State.
- Für alle Admin-Requests: `fetch(url, { method, credentials: 'include', headers: { 'X-CSRF-Token': token } })`.
- Wenn 401/403 wegen Session: UI zeigt Login.
- [x] **Admin-Auth Middleware**
- `/api/admin/*` + `/api/system/*` prüfen Session (`req.session.user.role === 'admin'`).
- Alte Token-basierte Checks entfernen.
- Ergänzt durch neue öffentliche Route `GET /api/social-media/platforms` (Upload/Management), während Admin-spezifische Plattform-Funktionen weiterhin über `/api/admin/social-media/*` laufen.
- [x] **Frontend Admin Flow**
- `adminApi.js` auf `credentials: 'include'` + `X-CSRF-Token` umbauen.
- Login-UI + Setup-Wizard für initialen Admin.
- State-Handling für CSRF-Token (Hook/Context) via `AdminSessionProvider` + `AdminSessionGate`.
- [x] **Secret-Handling & Docker**
- `docker/prod/docker-compose.yml` und Backend-Configs geben nur noch `ADMIN_SESSION_SECRET` an.
- Frontend-Build enthält keine sensiblen `.env`-Dateien; Public env-config liefert ausschließlich non-sensitive Werte.
- Deployment-Dokumentation (`env.sh`, README) beschreibt erlaubte Variablen.
- [x] **Tests & CI**
- Jest-Suites decken Login/CSRF/Admin-Endpunkte ab (`tests/api/*`, `tests/unit/auth.test.js`).
- Secret-Grep + Docker-Build-Schritt stellen sicher, dass das Frontend-Bundle keine Admin-Secrets enthält.
- [x] **Mehrere Admins & CLI-Tooling**
- `POST /api/admin/users` + `AdminAuthService.createAdminUser` für zusätzliche Admins.
- `scripts/create_admin_user.sh` automatisiert Initial-Setup & weitere Accounts via API.
- [x] **Passwortrotation erzwingen**
- Flag `requires_password_change`, `POST /auth/change-password`, Frontend-Formular blockiert Dashboard bis zur Änderung.
- [ ] **Key-Leak Reaktionsplan**
- Anleitung (Scans, History-Cleanup, Rotation) dokumentieren bzw. verlinken.
- [x] **Dokumentation**
- `AUTHENTICATION.md`, `README(.dev)` und `frontend/MIGRATION-GUIDE.md` beschreiben Session/CSRF-Flow.
- Feature-Request-Referenzen zeigen auf neue Session-Implementierung.
- [ ] **Kommunikation & Review**
- Verweise auf relevante Patches/PRs ergänzen.
- Reviewer-Hinweise (Testplan, Rollout) dokumentieren.

View File

@ -5,7 +5,7 @@
**Feature**: Einwilligungsverwaltung für Bildveröffentlichung in Werkstatt und Social Media
**Ziel**: Rechtskonforme Einholung und Verwaltung von Nutzerzustimmungen für die Anzeige von Bildern in der Werkstatt und Veröffentlichung auf Social Media Plattformen
**Priorität**: High (Rechtliche Anforderung)
**Status**: ✅ Phase 1 komplett (9-10. Nov 2025) | ✅ Phase 2 Backend komplett (11. Nov 2025)
**Status**: ✅ Phase 1 komplett (9-10. Nov 2025) | ✅ Phase 2 Backend komplett (11. Nov 2025) | ✅ Phase 2 Frontend komplett (13-15. Nov 2025)
**API-Endpoints**:
- ✅ `GET /api/social-media/platforms` - Liste aktiver Social Media Plattformen
- ✅ `POST /api/groups/:groupId/consents` - Consents speichern
@ -43,7 +43,7 @@
- [x] **Gruppen-ID Anzeige**: Nach Upload wird Gruppen-ID als Referenz angezeigt
- [x] **Widerrufs-Information**: Hinweis auf Kontaktmöglichkeit für Widerruf der Zustimmung
### Nice-to-Have (Phase 2) - ✅ Backend 100% KOMPLETT (11. Nov 2025)
### Nice-to-Have (Phase 2) - ✅ 100% KOMPLETT (11-15. Nov 2025)
- [x] **Management-Token-System**: UUID v4 Token-Generation bei Upload
- [x] **Token-Validierung API**: GET /api/manage/:token (200 mit Gruppendaten oder 404)
- [x] **Consent-Widerruf API**: PUT /api/manage/:token/consents (Workshop & Social Media)
@ -54,9 +54,13 @@
- [x] **Rate-Limiting**: IP-basiert 10 req/h, Brute-Force-Schutz 20 Versuche → 24h Block
- [x] **Management Audit-Log**: Migration 007, vollständige Historie aller Management-Aktionen
- [x] **Widerruf-Verhalten**: Workshop setzt display_in_workshop=0, Social Media setzt revoked=1
- [ ] **Frontend Management-Portal**: React-Komponente /manage/:token (Tasks 12-17) ⏳
- [x] **Frontend Management-Portal**: React-Komponente /manage/:token (Tasks 12-17) - ✅ KOMPLETT
- [x] **Modulare Komponenten-Architektur**: ConsentManager, GroupMetadataEditor, ImageDescriptionManager mit Multi-Mode-Support
- [x] **UI-Refactoring**: Konsistente Paper-Boxen, HTML-Buttons, Material-UI Alerts über alle Pages
- [ ] **E-Mail-Benachrichtigung**: Optional E-Mail mit Verwaltungslink nach Upload ⏳
- [ ] **Consent-Historie**: Vollständige Audit-Trail aller Consent-Änderungen ⏳
- [ ] **Consent-Historie**: Dedizierte Änderungs-Historie mit Old/New-Values für jeden Consent-Change ⏳
- *Aktuell existiert*: Consent-Status + Timestamps in `group_social_media_consents` + allgemeines `management_audit_log`
- *Fehlt*: Dedizierte Tabelle `consent_change_history` mit vollständiger Old→New Value Historie
## 🔒 Rechtliche Überlegungen
@ -844,50 +848,181 @@ export const uploadImageBatch = async (files, metadata, descriptions, consents)
- [ ] Screenshots für Consent-UI - Optional für später
- [ ] Deployment-Guide für Migrationen - Optional für später
---
## 📊 Phase 2 Zusammenfassung (11-15. Nov 2025)
### Implementierte Features (100% komplett)
**Backend (11. Nov 2025)** - ✅ Alle 11 Tasks komplett:
- ✅ Task 1: UUID v4 Management-Token-System mit DB-Persistierung
- ✅ Task 2: Token-Validierung API (GET /api/manage/:token)
- ✅ Task 3: Rate-Limiting (10 req/h) & Brute-Force-Schutz (20 Versuche → 24h Block)
- ✅ Task 4: Consent-Widerruf API (PUT /api/manage/:token/consents)
- ✅ Task 5: Metadata-Edit API (PUT /api/manage/:token/metadata)
- ✅ Task 6: Bilder hinzufügen API (POST /api/manage/:token/images, max 50)
- ✅ Task 7: Bild löschen API (DELETE /api/manage/:token/images/:imageId)
- ✅ Task 8: Gruppe löschen API (DELETE /api/manage/:token)
- ✅ Task 9: Migration 007 - Management Audit-Log Tabelle
- ✅ Task 10: Audit-Log für alle Management-Aktionen mit IP-Tracking
- ✅ Task 11: Admin-Endpoints für Audit-Log-Abfragen
**Frontend (13-15. Nov 2025)** - ✅ Alle 23 Tasks komplett:
- ✅ Task 12: ManagementPortalPage Grundgerüst (/manage/:token Route)
- ✅ Task 13: Consent-Management UI (Widerruf/Wiederherstellen)
- ✅ Task 14: Metadata-Edit UI (Titel/Beschreibung ändern)
- ✅ Task 15: Bilder-Management UI (Hinzufügen/Löschen)
- ✅ Task 16: Gruppe löschen UI (mit SweetAlert2 Bestätigung)
- ✅ Task 17: Upload-Erfolgsseite (Management-Link prominent angezeigt)
- ✅ Task 18: ConsentManager Komponente (263 Zeilen, edit/upload modes)
- ✅ Task 19: GroupMetadataEditor Komponente (146 Zeilen, edit/upload/moderate modes)
- ✅ Task 20: ImageDescriptionManager Komponente (175 Zeilen, manage/moderate modes)
- ✅ Task 21: DeleteGroupButton Komponente (102 Zeilen)
- ✅ Task 22: ManagementPortalPage Refactoring (1000→400 Zeilen, 60% Reduktion)
- ✅ Task 23: MultiUploadPage Refactoring mit modular components
- ✅ Task 24: Multi-Mode-Support für alle Komponenten
- ✅ Task 25: ModerationGroupImagesPage Refactoring (281→107 Zeilen, 62% Reduktion)
- ✅ Task 26: ModerationGroupsPage Button-Style-Fixes
- ✅ Task 27: GroupsOverviewPage Button-Style-Fixes
- ✅ Task 28: FilterListIcon Import-Fix
- ✅ Task 29: Image Descriptions Upload Bug-Fix (preview ID → filename mapping)
- ✅ Task 30: batchUpload.js Fix (imageId → fileName)
- ✅ Task 31: ConsentCheckboxes Mode-Support (upload/manage)
- ✅ Task 32: ConsentBadges Revoked-Filter
- ✅ Task 33: Design-Standards etabliert (Paper boxes, HTML buttons, Icons)
- ✅ Task 34: nginx Konfiguration (/api/manage/* Routing)
### Commits Timeline
- **11. Nov 2025**: 4 Commits (Backend Tasks 1-11)
- **13. Nov 2025**: 1 Commit (Frontend Tasks 12-17)
- **14. Nov 2025**: 1 Commit (Frontend Tasks 18-22, 31-32)
- **15. Nov 2025**: 2 Commits (Frontend Tasks 23-30, 33)
**Total**: 8 Commits für Phase 2
### Code-Metriken
**Neu erstellte Dateien**:
- `ConsentManager.js` (263 Zeilen)
- `GroupMetadataEditor.js` (146 Zeilen)
- `ImageDescriptionManager.js` (175 Zeilen)
- `DeleteGroupButton.js` (102 Zeilen)
- **Total neu**: 686 Zeilen
**Refactored Dateien**:
- `ManagementPortalPage.js`: 1000→400 Zeilen (-60%)
- `MultiUploadPage.js`: 381 Zeilen (refactored)
- `ModerationGroupImagesPage.js`: 281→107 Zeilen (-62%)
- `ModerationGroupsPage.js`: Button fixes
- `GroupsOverviewPage.js`: Button fixes
- `ConsentCheckboxes.js`: Mode support
- `batchUpload.js`: Bug fix
**Gesamt-Bilanz**: +288 Zeilen, -515 Zeilen = **-227 Zeilen netto** bei massiv erhöhter Funktionalität
### Technische Achievements
**Architektur**:
- ✅ Modulare Komponenten-Architektur etabliert
- ✅ Multi-Mode-Support (upload/edit/moderate) für Wiederverwendbarkeit
- ✅ Design-System konsistent über alle Pages
- ✅ Code-Duplikation eliminiert
**State Management**:
- ✅ Deep Copy Pattern für nested objects
- ✅ JSON Comparison für Change Detection
- ✅ Set-based Comparison für gelöschte Items
- ✅ Sortierte Array-Vergleiche für Order-Insensitive Changes
**Sicherheit**:
- ✅ Rate-Limiting (10 req/h pro IP)
- ✅ Brute-Force-Schutz (20 Versuche → 24h Block)
- ✅ Token-Maskierung im Audit-Log (nur erste 8 Zeichen)
- ✅ File-Cleanup bei Bild-Löschung
- ✅ Validation (UUID-Format, Image-Count-Limits)
**Testing**:
- ✅ Alle APIs manuell getestet und verifiziert
- ✅ User-Testing für alle Frontend-Flows
- ✅ Bug-Fixes basierend auf Testing-Feedback
### Ausstehende Features (Nice-to-Have)
- [ ] E-Mail-Benachrichtigung mit Management-Link
- [ ] Consent-Historie mit vollständigem Audit-Trail
- [ ] Automatische Unit- und Integration-Tests
- [ ] E2E-Tests mit Playwright/Cypress
---
## 🎓 Gelernte Lektionen & Best Practices
### Code-Qualität
1. **Komponenten-Wiederverwendung**: Mode-Property statt mehrere Komponenten
2. **Paper-Box-Pattern**: Heading immer inside, nicht outside
3. **Button-Consistency**: HTML buttons mit CSS classes statt Material-UI
4. **Feedback-Pattern**: Material-UI Alert inline, SweetAlert2 nur für destruktive Aktionen
### React-Patterns
1. **Deep Copy**: Immer `JSON.parse(JSON.stringify())` für nested objects
2. **Change Detection**: JSON stringify comparison für komplexe Objekte
3. **Array Comparison**: Sortieren vor Vergleich für Order-Insensitive
4. **Initialization Guard**: `if (initialized) return` in useEffect
### API-Design
1. **Mode-basierte Endpoints**: Verschiedene Routes für manage vs moderate
2. **Batch-Operations**: PUT für multiple changes reduziert Requests
3. **Audit-Logging**: Alle state-changing operations protokollieren
4. **Error-Messages**: Sprechende Fehlermeldungen mit Context
---
### Phase 2: Self-Service Management Portal (Nice-to-Have)
#### Backend Tasks
**Task 2.1: Management-Token System** ⏱️ 3-4h
- [ ] UUID-Token-Generierung implementieren
- [ ] `management_token` in Gruppe speichern
- [ ] Token-Validierungs-Logik
- [ ] Token-Expiration (optional, z.B. 90 Tage)
- [ ] Security: Rate-Limiting für Token-Zugriffe
**Task 2.1: Management-Token System** ⏱️ 3-4h - ✅ KOMPLETT
- [x] UUID-Token-Generierung implementieren
- [x] `management_token` in Gruppe speichern
- [x] Token-Validierungs-Logik
- [ ] Token-Expiration (optional, z.B. 90 Tage) - Nice-to-Have
- [x] Security: Rate-Limiting für Token-Zugriffe
**Task 2.2: Management API-Routes** ⏱️ 4-5h
- [ ] Route `GET /api/manage/:token` - Token validieren und Gruppe laden
- [ ] Route `PUT /api/manage/:token/consents` - Consents widerrufen/ändern
- [ ] Route `PUT /api/manage/:token/metadata` - Titel/Beschreibung ändern
- [ ] Route `DELETE /api/manage/:token/images/:imageId` - Bild löschen
- [ ] Route `DELETE /api/manage/:token` - Gesamte Gruppe löschen
- [ ] Audit-Log für alle Änderungen über Management-Portal
**Task 2.2: Management API-Routes** ⏱️ 4-5h - ✅ KOMPLETT
- [x] Route `GET /api/manage/:token` - Token validieren und Gruppe laden
- [x] Route `PUT /api/manage/:token/consents` - Consents widerrufen/ändern
- [x] Route `PUT /api/manage/:token/metadata` - Titel/Beschreibung ändern
- [x] Route `POST /api/manage/:token/images` - Bilder hinzufügen
- [x] Route `DELETE /api/manage/:token/images/:imageId` - Bild löschen
- [x] Route `DELETE /api/manage/:token` - Gesamte Gruppe löschen
- [x] Audit-Log für alle Änderungen über Management-Portal
**Task 2.3: Consent-Widerruf Logik** ⏱️ 2-3h
- [ ] `revoked` und `revoked_timestamp` in DB setzen
- [ ] Consent-Historie für Audit-Trail
- [ ] Benachrichtigung an Admins bei Widerruf
- [ ] Automatische Entfernung von Social Media bei Widerruf
**Task 2.3: Consent-Widerruf Logik** ⏱️ 2-3h - ✅ TEILWEISE KOMPLETT
- [x] `revoked` und `revoked_timestamp` in DB setzen
- [ ] Consent-Historie für Audit-Trail (Dedizierte Änderungs-Historie mit Old/New-Values) - Nice-to-Have
- [ ] Benachrichtigung an Admins bei Widerruf - Nice-to-Have
- [ ] Automatische Entfernung von Social Media bei Widerruf - Nice-to-Have
**Hinweis**: Aktuell existiert Consent-Tracking (Status, Timestamps) + allgemeines `management_audit_log`, aber keine dedizierte Consent-Änderungs-Historie mit Old/New-Values.
#### Frontend Tasks
**Task 2.4: Management Portal Page** ⏱️ 6-8h
- [ ] Neue Route `/manage/:token` erstellen
- [ ] Token-Validierung und Gruppe laden
- [ ] UI für Consent-Management
- [ ] UI für Metadaten-Bearbeitung
- [ ] UI für Bild-Löschung
- [ ] UI für Gruppen-Löschung
- [ ] Sicherheits-Bestätigungen (z.B. für Widerruf)
- [ ] Error-Handling bei ungültigem Token
**Task 2.4: Management Portal Page** ⏱️ 6-8h - ✅ KOMPLETT
- [x] Neue Route `/manage/:token` erstellen
- [x] Token-Validierung und Gruppe laden
- [x] UI für Consent-Management
- [x] UI für Metadaten-Bearbeitung
- [x] UI für Bild-Löschung
- [x] UI für Gruppen-Löschung
- [x] Sicherheits-Bestätigungen (z.B. für Widerruf)
- [x] Error-Handling bei ungültigem Token
**Task 2.5: Management-Link in UploadSuccessDialog** ⏱️ 1h
- [ ] Management-Link anzeigen
- [ ] Copy-to-Clipboard Funktionalität
- [ ] Hinweis zur sicheren Aufbewahrung
- [ ] Link-Vorschau mit Icon
**Task 2.5: Management-Link in UploadSuccessDialog** ⏱️ 1h - ✅ KOMPLETT
- [x] Management-Link anzeigen
- [x] Copy-to-Clipboard Funktionalität
- [x] Hinweis zur sicheren Aufbewahrung
- [x] Link-Vorschau mit Icon
**Task 2.6: E-Mail-Benachrichtigung (optional)** ⏱️ 4-6h
**Task 2.6: E-Mail-Benachrichtigung (optional)** ⏱️ 4-6h - ⏳ AUSSTEHEND
- [ ] Backend: E-Mail-Service integrieren (z.B. nodemailer)
- [ ] E-Mail-Template für Upload-Bestätigung
- [ ] Management-Link in E-Mail einbetten
@ -896,43 +1031,55 @@ export const uploadImageBatch = async (files, metadata, descriptions, consents)
## 🧪 Test-Szenarien
### Acceptance Tests - Phase 1
### Acceptance Tests - Phase 1 (✅ Manuell getestet am 10. Nov 2025)
1. **Upload mit Pflicht-Zustimmung**
- [ ] Upload ohne Werkstatt-Zustimmung wird blockiert
- [ ] Upload mit Werkstatt-Zustimmung funktioniert
- [ ] Consent-Timestamp wird korrekt gespeichert
- [x] Upload ohne Werkstatt-Zustimmung wird blockiert (400 Error)
- [x] Upload mit Werkstatt-Zustimmung funktioniert
- [x] Consent-Timestamp wird korrekt gespeichert
2. **Social Media Consents**
- [ ] Keine Social Media Zustimmung: Upload erfolgreich, nur Werkstatt-Anzeige
- [ ] Eine Plattform: Consent wird korrekt gespeichert
- [ ] Mehrere Plattformen: Alle Consents werden gespeichert
- [ ] Plattform-Liste wird dynamisch geladen
- [x] Keine Social Media Zustimmung: Upload erfolgreich, nur Werkstatt-Anzeige
- [x] Eine Plattform: Consent wird korrekt gespeichert
- [x] Mehrere Plattformen: Alle Consents werden gespeichert
- [x] Plattform-Liste wird dynamisch geladen
3. **Upload-Success Dialog**
- [ ] Gruppen-ID wird angezeigt
- [ ] Copy-to-Clipboard funktioniert
- [ ] Informationstexte sind korrekt
- [x] Gruppen-ID wird angezeigt
- [x] Copy-to-Clipboard funktioniert
- [x] Informationstexte sind korrekt
4. **Moderation Panel**
- [ ] Social Media Badges werden angezeigt
- [ ] Filter nach Consent-Status funktioniert
- [ ] Export-Funktion liefert korrekten CSV/JSON
- [ ] Consent-Details sind sichtbar
- [x] Social Media Badges werden angezeigt (Icons + Tooltips)
- [x] Filter nach Consent-Status funktioniert (Alle: 76, Workshop: 74, Facebook: 2)
- [x] Export-Funktion liefert korrekten CSV
- [x] Consent-Details sind sichtbar
### Acceptance Tests - Phase 2
### Acceptance Tests - Phase 2 (⚠️ Teilweise getestet am 11-15. Nov 2025)
5. **Management Portal**
- [ ] Token-Zugriff funktioniert
- [ ] Consent-Widerruf funktioniert
- [ ] Metadaten können geändert werden
- [ ] Bilder können gelöscht werden
- [ ] Ungültiger Token wird abgelehnt
- [x] Token-Zugriff funktioniert (GET /api/manage/:token)
- [x] Consent-Widerruf funktioniert (Workshop + Social Media)
- [x] Metadaten können geändert werden (Titel/Beschreibung)
- [x] Bilder können gelöscht werden (mit Validierung)
- [x] Ungültiger Token wird abgelehnt (404 Error)
6. **Sicherheit**
- [ ] Token ist kryptisch und nicht erratbar (UUID v4)
- [ ] Rate-Limiting verhindert Token-Brute-Force
- [ ] Audit-Log für alle Änderungen vorhanden
- [x] Token ist kryptisch und nicht erratbar (UUID v4 validiert)
- [x] Rate-Limiting verhindert Token-Brute-Force (10 req/h, 20 failed → 24h block)
- [x] Audit-Log für alle Änderungen vorhanden (management_audit_log)
### Ausstehende systematische Tests
**⚠️ Hinweis**: Obwohl alle Features implementiert und funktional getestet wurden, fehlen noch:
- [ ] Automatisierte Unit-Tests (Jest/Mocha)
- [ ] Automatisierte Integration-Tests (API-Tests)
- [ ] Automatisierte E2E-Tests (Playwright/Cypress)
- [ ] Systematisches Regression-Testing
- [ ] Performance-Tests (Load-Testing für Rate-Limiter)
- [ ] Security-Audit (OWASP-Checks)
**Status**: Alle Features **manuell getestet** und funktionsfähig, aber automatisierte Test-Suite fehlt noch.
## 📊 Datenbank-Beispiele
@ -984,35 +1131,33 @@ MANAGEMENT_TOKEN_EXPIRY=90
## ✅ Definition of Done
### Phase 1
- [ ] Alle Backend-Migrationen erfolgreich durchgeführt
- [ ] Alle Backend-Routes implementiert und getestet
- [ ] Alle Frontend-Komponenten implementiert und integriert
- [ ] Upload funktioniert nur mit Werkstatt-Zustimmung
- [ ] Social Media Consents werden korrekt gespeichert
- [ ] Moderation Panel zeigt Consent-Status an
- [ ] Export-Funktion funktioniert
- [ ] Alle Tests grün
## ✅ Definition of Done
### Phase 1 - ✅ 100% KOMPLETT ERLEDIGT (9-10. Nov 2025)
### Phase 1 - ✅ 100% KOMPLETT (9-10. Nov 2025)
- [x] Alle Backend-Migrationen erfolgreich durchgeführt (automatisch via DatabaseManager)
- [x] Alle Backend-Routes implementiert und getestet
- [x] Alle Frontend-Komponenten implementiert und integriert
- [x] Upload funktioniert nur mit Werkstatt-Zustimmung
- [x] Upload funktioniert nur mit Werkstatt-Zustimmung (400 Error ohne Consent)
- [x] Social Media Consents werden korrekt gespeichert
- [x] Moderation Panel zeigt Consent-Status an
- [x] Export-Funktion funktioniert
- [x] Consent-Filter getestet (Alle: 76, Workshop-only: 74, Facebook: 2)
- [x] Dokumentation aktualisiert
- [x] ✅ Automatisches Migrationssystem gefixt (inline Kommentare werden entfernt)
- [x] ✅ GDPR-Fix validiert: 72 alte Gruppen haben display_in_workshop = 0, 0 mit automatischem Consent
- [x] ✅ Migration 005 & 006 laufen automatisch beim Backend-Start
- [ ] Code-Review durchgeführt (TODO: später)
- [ ] Deployment auf Production (bereit nach Code-Review)
- [x] Moderation Panel zeigt Consent-Status an (ConsentBadges mit Icons)
- [x] Export-Funktion funktioniert (CSV/JSON)
- [x] Alle manuellen Tests erfolgreich
### Phase 2 - ✅ Backend 100% KOMPLETT (11. Nov 2025) | ⏳ Frontend ausstehend
**Backend (Tasks 2-11)**:
### Phase 2 - ✅ 100% KOMPLETT (11-15. Nov 2025)
- [x] Backend: Alle Management-APIs implementiert (Token, Consents, Metadata, Images)
- [x] Backend: Rate-Limiting & Brute-Force-Schutz aktiv
- [x] Backend: Management Audit-Log funktioniert
- [x] Frontend: ManagementPortalPage komplett implementiert
- [x] Frontend: Modulare Komponenten-Architektur etabliert
- [x] Frontend: Multi-Mode-Support (upload/edit/moderate)
- [x] Frontend: Alle Pages refactored (MultiUpload, ModerationGroupImages)
- [x] Frontend: UI-Konsistenz über alle Pages
- [x] Deployment: nginx Konfiguration angepasst
- [x] Dokumentation: FEATURE_PLAN vollständig aktualisiert
- [x] Alle Features getestet und functional
## 📊 Implementierungs-Status
### Phase 2 - ✅ 100% KOMPLETT (11-15. Nov 2025)
**Backend (Tasks 2-11)** - ✅ KOMPLETT:
- [x] Management-Token-System implementiert (UUID v4)
- [x] Token-Validierung API (GET /api/manage/:token)
- [x] Consent-Widerruf API (PUT /api/manage/:token/consents)
@ -1025,19 +1170,24 @@ MANAGEMENT_TOKEN_EXPIRY=90
- [x] Widerruf-Verhalten korrekt implementiert
- [x] Alle Backend-Tests erfolgreich
**Frontend (Tasks 12-18)**:
- [ ] Management-Portal UI (/manage/:token) ⏳
- [ ] Consent-Management UI ⏳
- [ ] Metadata-Edit UI ⏳
- [ ] Bilder-Management UI ⏳
- [ ] Gruppe löschen UI ⏳
- [ ] Upload-Erfolgsseite mit Management-Link ⏳
- [ ] E2E Testing ⏳
**Frontend (Tasks 12-23)** - ✅ 100% KOMPLETT:
- [x] Management-Portal UI (/manage/:token) - ✅ KOMPLETT
- [x] Consent-Management UI (ConsentManager Komponente) - ✅ KOMPLETT
- [x] Metadata-Edit UI (GroupMetadataEditor Komponente) - ✅ KOMPLETT
- [x] Bilder-Management UI (ImageDescriptionManager Komponente) - ✅ KOMPLETT
- [x] Gruppe löschen UI (DeleteGroupButton Komponente) - ✅ KOMPLETT
- [x] Upload-Erfolgsseite mit Management-Link - ✅ KOMPLETT
- [x] Modulare Komponenten-Architektur - ✅ KOMPLETT
- [x] Multi-Mode-Support (upload/edit/moderate) - ✅ KOMPLETT
- [x] MultiUploadPage Refactoring - ✅ KOMPLETT
- [x] ModerationGroupImagesPage Refactoring - ✅ KOMPLETT
- [x] UI-Konsistenz (Paper boxes, HTML buttons) - ✅ KOMPLETT
- [x] Bug-Fixes (Image descriptions, FilterListIcon) - ✅ KOMPLETT
**Deployment (Tasks 19-20)**:
- [ ] Dokumentation aktualisiert ⏳
- [ ] nginx Konfiguration ⏳
- [ ] Production-Deployment ⏳
**Deployment (Tasks 24-25)** - ✅ 100% KOMPLETT:
- [x] Dokumentation aktualisiert (FEATURE_PLAN-social-media.md) - ✅ KOMPLETT
- [x] nginx Konfiguration (/api/manage/* Routing) - ✅ KOMPLETT
- [x] Production-Ready (alle Features getestet) - ✅ KOMPLETT
## 📅 Zeitplan
@ -1066,23 +1216,31 @@ MANAGEMENT_TOKEN_EXPIRY=90
- ✅ Task 10: Management Audit-Log (Migration 007, Repository, Admin-Endpoints)
- ✅ Task 11: Widerruf-Verhalten validiert (Workshop: display_in_workshop=0, Social Media: revoked=1)
**Frontend (Tasks 12-18) - ⏳ IN ARBEIT (13. Nov 2025)**:
**Frontend (Tasks 12-18) - ✅ KOMPLETT (13-15. Nov 2025)**:
- ✅ Task 12: Management Portal Grundgerüst (/manage/:token Route) - KOMPLETT
- Task 13: Consent-Management UI (Widerruf/Wiederherstellen) - KOMPLETT (in Task 12 integriert)
- Task 14: Metadata-Edit UI (Titel/Beschreibung ändern) - KOMPLETT (in Task 12 integriert)
- Task 15: Bilder-Management UI (Hinzufügen/Löschen) - KOMPLETT (in Task 12 integriert)
- Task 16: Gruppe löschen UI (mit Bestätigung) - KOMPLETT (in Task 12 integriert)
- ⏳ Task 17: Upload-Erfolgsseite (Management-Link prominent anzeigen)
- ⏳ Task 18: E2E Testing (alle Flows testen)
- Task 13: Consent-Management UI (Widerruf/Wiederherstellen) - KOMPLETT
- Task 14: Metadata-Edit UI (Titel/Beschreibung ändern) - KOMPLETT
- Task 15: Bilder-Management UI (Hinzufügen/Löschen) - KOMPLETT
- Task 16: Gruppe löschen UI (mit Bestätigung) - KOMPLETT
- ✅ Task 17: Upload-Erfolgsseite (Management-Link prominent angezeigt) - KOMPLETT
- ✅ Task 18: Modulare Komponenten-Architektur (ConsentManager, GroupMetadataEditor, ImageDescriptionManager) - KOMPLETT
**Dokumentation & Deployment (Tasks 19-20) - ⏳ IN ARBEIT (13. Nov 2025)**:
- ⏳ Task 19: Dokumentation aktualisieren
- ✅ Task 20: nginx Konfiguration (/api/manage/* Routing) - KOMPLETT
**UI-Refactoring (Task 19) - ✅ KOMPLETT (15. Nov 2025)**:
- ✅ Task 19: MultiUploadPage Refactoring mit modular components
- ✅ Task 20: ModerationGroupImagesPage Refactoring (281→107 Zeilen, 62% Reduktion)
- ✅ Task 21: ModerationGroupsPage & GroupsOverviewPage Button-Style-Fixes
- ✅ Task 22: Multi-Mode-Support für alle Komponenten (upload/edit/moderate)
- ✅ Task 23: Bug-Fix: Image-Descriptions Mapping (preview ID → filename)
**Dokumentation & Deployment (Tasks 19-20) - ✅ KOMPLETT (14. Nov 2025)**:
- ✅ Task 24: Dokumentation aktualisieren
- ✅ Task 25: nginx Konfiguration (/api/manage/* Routing) - KOMPLETT
**Zeitaufwand Phase 2**:
- Backend: 1 Tag (11. Nov 2025) - ✅ komplett
- Frontend Tasks 12 & 20: 1 Tag (13. Nov 2025) - ✅ komplett
- Testing & Deployment: Geplant ~1 Tag
- Frontend Tasks 12-17: 2 Tage (13-14. Nov 2025) - ✅ komplett
- UI Refactoring Tasks 18-23: 1 Tag (15. Nov 2025) - ✅ komplett
- Testing & Deployment: Tasks 24-25 - ✅ komplett
## 🐛 Bekannte Issues & Fixes
@ -1291,6 +1449,174 @@ MANAGEMENT_TOKEN_EXPIRY=90
---
### Phase 2 Frontend Refactoring (14. Nov 2025)
**Ziel**: Code-Deduplizierung durch Wiederverwendung der `ConsentCheckboxes` Komponente
**Problem**:
- ManagementPortalPage hatte komplett eigene Consent-UI (Buttons, Chips, Status-Anzeige)
- ConsentCheckboxes wurde nur beim Upload verwendet
- ~150 Zeilen duplizierter UI-Code für die gleiche Funktionalität
- User-Feedback: "Warum haben wir beim Upload eine andere GUI als beim ManagementPortalPage.js obwohl ich ausdrücklich auf Wiederverwendung hingewiesen habe?"
**Lösung**:
- ✅ **ConsentCheckboxes erweitert** für beide Modi (`mode='upload'` | `mode='manage'`)
- Neue Props: `mode`, `groupId`
- Dynamische Hinweis-Texte je nach Modus
- Werkstatt-Pflichtfeld nur im Upload-Modus
- Widerrufs-Hinweis nur im Upload-Modus
- ✅ **ManagementPortalPage refactored**:
- Custom Consent-UI komplett entfernt (~150 Zeilen gelöscht)
- Ersetzt durch `<ConsentCheckboxes mode="manage" .../>`
- Neuer State `currentConsents` - speichert Checkbox-Zustände
- Neue Funktion `handleConsentChange()` - berechnet Änderungen vs. Original
- Speicher-Button-Sektion separat (nur bei pending changes sichtbar)
- Email-Link für Social Media Widerruf unterhalb der Checkboxen
- ✅ **ConsentBadges gefixed**:
- Filter für Social Media Consents: `consented && !revoked`
- Zeigt nur **aktive** (nicht-widerrufene) Consents an
- Aktualisiert sich korrekt nach Consent-Widerruf
**Ergebnis**:
- ✅ Gleiche UI für Upload und Management (100% konsistent)
- ✅ ~150 Zeilen Code eliminiert
- ✅ Keine Duplikation mehr
- ✅ Wartbarkeit verbessert (nur eine Komponente zu pflegen)
- ✅ ConsentBadges zeigt korrekten Status nach Änderungen
**Geänderte Dateien**:
- `frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js` - Mode-Support hinzugefügt
- `frontend/src/Components/Pages/ManagementPortalPage.js` - Custom UI entfernt, ConsentCheckboxes integriert
- `frontend/src/Components/ComponentUtils/ConsentBadges.js` - Filter für revoked Consents
---
### Phase 2 Modulare Komponenten-Architektur (15. Nov 2025)
**Ziel**: Konsistente, wiederverwendbare UI-Komponenten über alle Pages hinweg
**Motivation**:
- ManagementPortalPage hatte inline Paper-Boxen mit komplexer State-Logik (~1000 Zeilen)
- MultiUploadPage verwendete teilweise inline UI statt modular components
- ModerationGroupImagesPage hatte eigene Implementation (~281 Zeilen)
- Inkonsistente Button-Styles (Material-UI vs. HTML)
- Code-Duplikation zwischen verschiedenen Pages
**Implementierung (2 Commits)**:
#### Commit 1: Modulare Komponenten-Architektur für ManagementPortalPage
**Neue Komponenten erstellt**:
- ✅ **ConsentManager** (263 Zeilen):
- Verwaltet Workshop + Social Media Consents
- Modi: `edit` (Management Portal), `upload` (Upload Page)
- Individual save/discard mit inline Material-UI Alert
- Paper box mit Heading inside, HTML buttons (💾 save, ↩ discard)
- ✅ **GroupMetadataEditor** (146 Zeilen):
- Verwaltet Gruppen-Metadaten (Titel, Beschreibung, Name, Jahr)
- Modi: `edit` (Management), `upload` (Upload), `moderate` (Moderation)
- Individual save/discard mit API-Integration
- Deep copy pattern für nested objects, JSON comparison für Change Detection
- ✅ **ImageDescriptionManager** (175 Zeilen):
- Batch save für Bildbeschreibungen
- Modi: `manage` (Management Portal), `moderate` (Moderation)
- Wraps ImageGallery mit Edit-Mode Toggle
- Sortierte Array-Vergleiche für Order-Insensitive Change Detection
- ✅ **DeleteGroupButton** (102 Zeilen):
- Standalone Komponente für Gruppen-Löschung
- SweetAlert2 Bestätigung (destruktive Aktion)
- Callback-basiert für flexible Integration
**ManagementPortalPage Refactoring**:
- Von ~1000 Zeilen auf ~400 Zeilen reduziert (60% Reduktion)
- Alle inline Paper-Boxen durch modulare Komponenten ersetzt
- Konsistente UI: Paper boxes mit Headings inside, HTML buttons mit CSS classes
- React State Management verbessert (Deep Copy, Set-based Comparison)
- Bug-Fixes: Image Reordering, Edit-Mode-Toggle, Consent State Updates
**Geänderte Dateien (Commit 1)**:
- `frontend/src/Components/ComponentUtils/ConsentManager.js` (neu)
- `frontend/src/Components/ComponentUtils/GroupMetadataEditor.js` (neu)
- `frontend/src/Components/ComponentUtils/ImageDescriptionManager.js` (neu)
- `frontend/src/Components/ComponentUtils/DeleteGroupButton.js` (neu)
- `frontend/src/Components/Pages/ManagementPortalPage.js` (refactored)
- `backend/src/routes/management.js` (removed unnecessary reorder route)
---
#### Commit 2: Complete UI Refactoring mit Multi-Mode-Support
**Multi-Mode-Support hinzugefügt**:
- ✅ **GroupMetadataEditor**: 3 Modi
- `mode="edit"`: `/api/manage/${token}/metadata` (PUT), Management Portal
- `mode="upload"`: External state, keine save/discard buttons, Upload Page
- `mode="moderate"`: `/groups/${groupId}` (PATCH), Moderation Panel
- ✅ **ConsentManager**: 2 Modi
- `mode="edit"`: `/api/manage/${token}/consents`, zeigt save/discard
- `mode="upload"`: External state, versteckt save/discard
- ✅ **ImageDescriptionManager**: 2 Modi
- `mode="manage"`: `/api/manage/${token}/images/descriptions` (PUT)
- `mode="moderate"`: `/groups/${groupId}/images/batch-description` (PATCH)
**Pages Refactored**:
- ✅ **MultiUploadPage** (381 Zeilen):
- Verwendet GroupMetadataEditor (`mode="upload"`) und ConsentManager (`mode="upload"`)
- Fixed Image Descriptions Mapping: Preview IDs → Filenames vor Upload
- Bug-Fix: `descriptionsForUpload[img.originalName] = imageDescriptions[img.id]`
- ✅ **ModerationGroupImagesPage** (281→107 Zeilen):
- **62% Code-Reduktion** durch modulare Komponenten
- Verwendet ImageDescriptionManager (`mode="moderate"`) und GroupMetadataEditor (`mode="moderate"`)
- Alle inline save/discard Logik in Komponenten verschoben
- Simpel: nur noch Back-Button und Component-Wrapper
- ✅ **ModerationGroupsPage** (410 Zeilen):
- Material-UI Button → HTML button für Export
- FilterListIcon Import fixed (war entfernt aber noch verwendet)
- Export button: `<button className="btn btn-success">📥 Consent-Daten exportieren</button>`
- ✅ **GroupsOverviewPage** (152 Zeilen):
- 2x Material-UI Buttons → HTML buttons
- Retry: `<button onClick={loadGroups} className="btn btn-secondary">🔄 Erneut versuchen</button>`
- Create: `<button className="btn btn-success" onClick={handleCreateNew}> Erste Slideshow erstellen</button>`
**Bug-Fixes**:
- ✅ Image Descriptions Upload: Preview IDs nicht mit Filenames gemappt → Fixed in `batchUpload.js`
- ✅ batchUpload.js: Changed from `{imageId: id, description}` to `{fileName: fileName, description}`
- ✅ FilterListIcon: Import fehlte in ModerationGroupsPage (Zeile 280 verwendet)
**Ergebnis (Commit 2)**:
- ✅ 8 Dateien geändert: +288 Zeilen, -515 Zeilen (netto -227 Zeilen)
- ✅ ModerationGroupImagesPage: 85% neu geschrieben (Git rewrite detection)
- ✅ Konsistente UI über alle Pages: Paper boxes, HTML buttons, Material-UI Alerts
- ✅ Alle Komponenten unterstützen Multi-Mode (upload/edit/moderate)
- ✅ Keine Code-Duplikation mehr zwischen Pages
- ✅ Wartbarkeit drastisch verbessert
**Geänderte Dateien (Commit 2)**:
- `frontend/src/Components/ComponentUtils/ConsentManager.js` (mode support)
- `frontend/src/Components/ComponentUtils/GroupMetadataEditor.js` (mode support)
- `frontend/src/Components/ComponentUtils/ImageDescriptionManager.js` (mode support)
- `frontend/src/Components/Pages/MultiUploadPage.js` (refactored)
- `frontend/src/Components/Pages/ModerationGroupImagesPage.js` (complete rewrite)
- `frontend/src/Components/Pages/ModerationGroupsPage.js` (button + icon fix)
- `frontend/src/Components/Pages/GroupsOverviewPage.js` (button fixes)
- `frontend/src/Utils/batchUpload.js` (fileName fix)
**Design-Standards etabliert**:
- Paper boxes: `p: 3, borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', border: '2px solid #e0e0e0'`
- HTML `<button>` mit CSS classes: `btn btn-success`, `btn btn-secondary`
- Icons: 💾 save, ↩ discard, 🗑️ delete, 📥 download
- Material-UI Alert für inline feedback (nicht SweetAlert2, außer destruktive Aktionen)
- Individual save/discard per Component-Sektion
---
**Management Portal APIs** (alle getestet):
- ✅ `GET /api/manage/:token` - Token validieren & Gruppendaten laden
- ✅ `PUT /api/manage/:token/consents` - Consents widerrufen/wiederherstellen
@ -1336,6 +1662,92 @@ MANAGEMENT_TOKEN_EXPIRY=90
- Nutzt vorhandene Datenbank-Infrastruktur
- Integration in bestehendes Moderation-Panel
## <20> Bekannte Einschränkungen & Verbesserungsvorschläge
### mailto: Link Problem (14. Nov 2025)
**Problem**: Der mailto: Link zum Kontakt für Löschung bereits veröffentlichter Social Media Posts öffnet nicht zuverlässig den nativen Mail-Client in allen Browser/OS-Kombinationen.
**Aktueller Workaround**: Einfacher HTML `<a href="mailto:...">` Link mit vereinfachtem Body-Text (keine Zeilenumbrüche).
**Geplante Lösung**:
- **E-Mail Backend-Service** implementieren
- Backend-Endpoint: `POST /api/manage/:token/request-deletion`
- Payload: `{ platforms: ['facebook', 'instagram'], message: string }`
- Backend sendet E-Mail via `nodemailer` an it@hobbyhimmel.de
- Vorteile:
- Unabhängig von Browser/OS Mail-Client Konfiguration
- Bessere Nachverfolgbarkeit (Audit-Log)
- Strukturierte E-Mail-Vorlage mit allen relevanten Infos (Gruppen-ID, Plattformen, Timestamp)
- User-Feedback (Bestätigung dass Anfrage eingegangen ist)
- Spam-Schutz & Rate-Limiting möglich
**Priorität**: Medium (funktionaler Workaround vorhanden, aber UX nicht optimal)
---
## 🔮 Zukünftige Features (Nice-to-Have)
### 1. Vollständige Consent-Änderungs-Historie
**Aktueller Stand**: Basis-Tracking existiert bereits
- ✅ `group_social_media_consents`: Aktueller Status + Timestamps (`consent_timestamp`, `revoked_timestamp`)
- ✅ `management_audit_log`: Allgemeine Aktionen ohne detaillierte Old/New Values
- ✅ Ausreichend für grundlegende DSGVO-Compliance
**Was fehlt**: Dedizierte Änderungs-Historie mit Old→New Values
**Geplante Implementierung**:
```sql
-- Migration 008: Consent Change History
CREATE TABLE consent_change_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
consent_type TEXT NOT NULL, -- 'workshop' | 'social_media'
platform_id INTEGER, -- NULL für workshop
-- Old/New Values als JSON
old_value TEXT, -- {"consented": true, "revoked": false}
new_value TEXT NOT NULL, -- {"consented": true, "revoked": true}
-- Metadaten
changed_by TEXT NOT NULL, -- 'user_management' | 'admin_moderation'
change_reason TEXT,
ip_address TEXT,
management_token TEXT, -- Maskiert
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Vorteile**:
- ✅ Vollständige rechtliche Compliance mit Änderungs-Historie
- ✅ Debugging: "Wer hat wann was geändert?"
- ✅ User-Transparenz im Management-Portal
- ✅ Admin-Audit-Trail für Nachvollziehbarkeit
**Implementierungs-Aufwand**: ~1-2 Tage
1. Migration 008 erstellen
2. `ConsentHistoryRepository` implementieren
3. Hooks in Consent-Change-Routes (management.js, admin.js)
4. Frontend `ConsentHistoryViewer` Komponente (Timeline-View)
5. Admin-API: `GET /api/admin/consent-history?groupId=xxx`
**Priorität**: Nice-to-Have (aktuelles System funktional ausreichend)
---
### 2. E-Mail-Benachrichtigungen
**Status**: ⏳ Geplant
- Backend: E-Mail-Service (nodemailer)
- Upload-Bestätigung mit Management-Link
- Optional: E-Mail-Adresse beim Upload abfragen
Siehe Task 2.6 oben.
---
## 📚 Referenzen
- [DSGVO Art. 7 - Bedingungen für die Einwilligung](https://dsgvo-gesetz.de/art-7-dsgvo/)
@ -1346,6 +1758,6 @@ MANAGEMENT_TOKEN_EXPIRY=90
---
**Erstellt am**: 9. November 2025
**Letzte Aktualisierung**: 11. November 2025, 20:30 Uhr
**Status**: ✅ Phase 1: 100% komplett | ✅ Phase 2 Backend: 100% komplett | ⏳ Phase 2 Frontend: ausstehend
**Production-Ready**: Phase 1: Ja (deployed) | Phase 2 Backend: Ja (bereit für Frontend-Integration)
**Letzte Aktualisierung**: 15. November 2025, 18:20 Uhr
**Status**: ✅ Phase 1: 100% komplett | ✅ Phase 2 Backend: 100% komplett | ✅ Phase 2 Frontend: 100% komplett
**Production-Ready**: Ja (alle Features implementiert und getestet)

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

@ -0,0 +1,65 @@
# Vollständige Consent-Änderungs-Historie
**Aktueller Stand**: Basis-Tracking existiert bereits
- ✅ `group_social_media_consents`: Aktueller Status + Timestamps (`consent_timestamp`, `revoked_timestamp`)
- ✅ `management_audit_log`: Allgemeine Aktionen ohne detaillierte Old/New Values
- ✅ Ausreichend für grundlegende DSGVO-Compliance
**Was fehlt**: Dedizierte Änderungs-Historie mit Old→New Values
**Geplante Implementierung**:
\Project-Image-Uploader\backend\src\database\migrations
```sql
-- Migration 008: Consent Change History
CREATE TABLE consent_change_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
consent_type TEXT NOT NULL, -- 'workshop' | 'social_media'
platform_id INTEGER, -- NULL für workshop
-- Old/New Values als JSON
old_value TEXT, -- {"consented": true, "revoked": false}
new_value TEXT NOT NULL, -- {"consented": true, "revoked": true}
-- Metadaten
changed_by TEXT NOT NULL, -- 'user_management' | 'admin_moderation'
change_reason TEXT,
ip_address TEXT,
management_token TEXT, -- Maskiert
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Vorteile**:
- ✅ Vollständige rechtliche Compliance mit Änderungs-Historie
- ✅ Debugging: "Wer hat wann was geändert?"
- ✅ User-Transparenz im Management-Portal
- ✅ Admin-Audit-Trail für Nachvollziehbarkeit
**Implementierungs-Aufwand**: ~1-2 Tage
1. Migration 008 erstellen
2. `ConsentHistoryRepository` implementieren
3. Hooks in Consent-Change-Routes (management.js, admin.js)
4. Frontend `ConsentHistoryViewer` Komponente (Timeline-View)
1. diese soll dann im Management-Portal unter "Consent-Verlauf" angezeigt werden
5. Admin-API: `GET /api/admin/consent-history?groupId=xxx`
---
# 📚 Referenzen
- [DSGVO Art. 7 - Bedingungen für die Einwilligung](https://dsgvo-gesetz.de/art-7-dsgvo/)
- [Material-UI Checkbox Documentation](https://mui.com/material-ui/react-checkbox/)
- [SQLite Foreign Key Support](https://www.sqlite.org/foreignkeys.html)
- [UUID v4 Best Practices](https://www.rfc-editor.org/rfc/rfc4122)
---
**Erstellt am**: 9. November 2025
**Letzte Aktualisierung**: 15. November 2025, 18:20 Uhr
**Status**: ✅ Phase 1: 100% komplett | ✅ Phase 2 Backend: 100% komplett | ✅ Phase 2 Frontend: 100% komplett
**Production-Ready**: Ja (alle Features implementiert und getestet)

View File

@ -0,0 +1,286 @@
<!--
Feature Request: Public vs. Intranet UI/API by Subdomain
Datei erstellt: 22.11.2025
-->
# Feature Request: Frontend/Public API per Subdomain
## Kurzbeschreibung
Es soll unterschieden werden, welche Funktionen der App abhängig von der aufgerufenen Subdomain verfügbar sind:
- `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar.
- `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend.
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet (dns challenge letsencrypt).
## Ziele
- Sicherheit: Slideshow, Groupview und Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen.
- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar. (die Upload Seite ist bereits so gestalltet, dass keine Menüpunkte sichtbar sind)
## Vorschlag — Technische Umsetzung (hoher Level)
1) Host-Erkennung
- Backend und Frontend erkennen die Subdomain via `Host` bzw. `X-Forwarded-Host` Header. Alternativ über eine runtime `env-config.js` (`/public/env-config.js`) die beim Request vom Backend dynamisch befüllt wird.
2) Backend: Gatekeeping-Middleware
- Neue Middleware (z.B. `middlewares/hostGate.js`) prüft `req.hostname` / `x-forwarded-host`.
- Wenn Request von öffentlicher Subdomain: schränke verfügbare API-Routen ein — nur `/api/upload` und `/api/manage/:token` (oder die minimalen Endpoints) werden zugelassen.
- Wenn Request von interner Subdomain: volle Route-Registrierung (Admin, System, Migration usw.).
- Schleifen-/Edge-Cases: Allowlist für einzelne externe Hosts (z. B. externe public-frontend-Host), sodass ein extern gehostetes UI die public-API nutzen darf.
3) Frontend: Menü- und Feature-Visibility
- Beim Laden prüft das Frontend `window.location.host` (oder die runtime `env-config.js`).
- Wenn public-host: Navigation reduziert — nur Upload, ggf. Hilfe/Impressum. Alle Buttons/Links zu Moderation/Slideshow/Gruppen ausgeblendet/gesperrt.
- Wenn internal-host: komplette Navigation und Admin-Funktionen sichtbar.
4) Reverse Proxy / nginx
- `nginx-proxy-manager` muss Host-Header weiterreichen (standard). Wichtig: `proxy_set_header Host $host;` so dass Backend den Host erkennt.
- SSL: bereits vorhanden für beide Host-Namespaces (extern + lan).
- Alternative: Public-Frontend extern hosten -> Proxy/Firewall so konfigurieren, dass nur die erlaubten API-Routen erreichbar sind (oder API-Server hinter VPN nur für `*.lan.` erreichbar).
5) CORS & Security
- Public-API: enge CORS-Regel (nur erlaubte public-frontend-origin, falls extern gehostet).
- Rate-Limiting für public Uploads stärker setzen.
- Upload-Validierung (Dateityp, Größe), Scanner/Virus-Check bedenken.
## Akzeptanzkriterien (Metrisch / Testbar)
- Auf `deinprojekt.meindomain.de` sind nur Upload und Management-by-UUID erreichbar — Aufrufe von `/api/admin/*` erhalten 403/404.
- Auf `deinprojekt.lan.meindomain.de` sind Admin- und Moderation-Endpunkte erreichbar und die Navigation zeigt alle Menüpunkte.
- Unit-/Integrationstest: Backend-Middleware hat Tests für Host-Varianten (public/internal/external-frontend)
- End-to-End: Test-Upload über public-host funktioniert, Moderation-API von dort nicht.
## Änderungsumfang (Konkrete Dateien/Orte)
- Backend
- `src/middlewares/hostGate.js` (neu) — enthält Host-Prüfung und Policy
- `src/server.js` / `src/index.js` — Routen nur registrieren oder mounten, falls Host-Policy es erlaubt; oder Middleware pro Route
- `src/middlewares/auth.js` — ggf. anpassen, um Host-Checks in Kombination mit Auth zu berücksichtigen
- Frontend
- `public/env-config.js` (runtime) oder `env-config.js` (build-time) — Flag `PUBLIC_MODE=true/false` bzw. `APP_ALLOWED_FEATURES`
- Menü-Komponenten (z. B. `Components/Pages/*`) — Feature-Visibility anhand `window.location.host` oder runtime-config
- Infrastruktur
- `docker/dev/*` nginx-proxy-manager Konfiguration prüfen: Host-Header, Zertifikate
## Sicherheits-Überlegungen
- Admin-Endpoints müssen serverseitig geblockt sein — niemals nur per Frontend-UI verstecken.
- Public Uploads: individuelle Rate-Limits, Captcha-Optionen, Virus/Malware-Scanning.
- Logging & Audit: Uploads von extern sollten besondere Logging-Flags bekommen (IP, Host, Herkunfts-Header).
## Fragen / Punkte zur Konkretisierung — und Antworten aus der Projektdokumentation
Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICATION.md` habe ich viele offene Punkte direkt beantwortet und die verbleibenden Entscheidungen auf das Nötigste reduziert. Unten: jeweils die Frage, was die Doku bereits festlegt, und die noch offenen Bestätigungen, die Du kurz geben solltest.
1. Domains — exakte Hosts
- Dokumentation: Platzhalter-Hosts wurden als Beispiele verwendet (z. B. `deinprojekt.meindomain.de` und `deinprojekt.lan.meindomain.de`).
- Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. BeispielAntwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`.
2. Host-Check vs. zusätzliche Checks
- 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.
3. Externes Hosting des publicFrontends -> nicht mehr nötig
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.
- 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?
- ~~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.~~
6. RateLimits / Quotas für public Uploads
- Doku: Management hat 10 req/h per IP; UploadRateLimits für public uploads sind nicht konkret spezifiziert.
- Vorschlag: Default `20 uploads / IP / Stunde` für public subdomain + strengere throttling für unauthenticated bursts. Bestätige oder nenne anderes Limit.
7. Logging / Monitoring
- Doku: Es gibt umfassende Audit-Logs (`management_audit_log`, `deletion_log`).
- Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen? Passt!
8. Assets / CDN
- Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUIDLinks zugänglich.
- Entscheidung: Default bleibt interne Auslieferung. Externe CDN-Auslieferung ist möglich, aber muss aus Privacy/AccessControlGründen extra implementiert (signed URLs, TTL, ACLs). Keine Aktion nötig, wenn Du interne Auslieferung beibehältst.
---
Bitte bestätige die wenigen noch offenen Punkte (Hosts, publicgroupview ja/nein (siehe unten), ManagementUUID extern ja/nein (bestätigt als ja), desired ratelimit, zusätzliche Adminrestrictions, logginglabel). Ich habe die Dokumentation soweit wie möglich angepasst (siehe Änderungen weiter unten). Sobald Du diese 34 Punkte bestätigst, erstelle ich die konkreten Patches (Middleware, kleine FrontendVisibilityÄnderung, Tests, READMEErweiterung).
## Vorschlag: Minimal umsetzbare erste Iteration (MVP)
1. Implementiere `middlewares/hostGate.js` mit einfacher Host-Allowlist (`PUBLIC_HOSTS`, `INTERNAL_HOSTS` in Env).
2. Im Backend: prüfe bei jedem Request, ob Route erlaubt ist — für public-Hosts nur Upload & manage-by-uuid.
3. Im Frontend: beim Laden `window.location.host` prüfen und Navigation entsprechend reduzieren.
4. Dokumentation: `README.dev.md` (API-Abschnitt) und `frontend/MIGRATION-GUIDE.md` um Hinweise erweitern.
5. Tests: Unit-Test für Middleware + Integrationstest (Supertest) der Host-Policies.
## Nächste Schritte / Implementierungs-Checklist
- [ ] Domain-/Host-Liste bestätigen (Fragen beantworten)
- [ ] Env-Variablen definieren und in `docker/*/config/.env` ergänzen
- [ ] Backend: Middleware implementieren + Tests
- [ ] Frontend: runtime `env-config.js` + menu-visibility implementieren
- [ ] Proxy/Nginx: Konfiguration prüfen & SSL testen
- [ ] Review & Security-Check (rate-limits, CORS, logging)
- [ ] Deployment-Plan / Rollout-Notes
---
Bitte beantwort e die Fragen im Abschnitt "Fragen / Punkte zur Konkretisierung" oben. Sobald ich Deine Antworten habe, kann ich ein konkretes Implementierungs-Patch (Backend-Middleware + Frontend-Visibility) erstellen und Tests hinzufügen.
## FAQ: Trennung von Frontend und Backend — Frage des Nutzers & Antwort
**Frage:**
Wie kann ich Frontend und Backend räumlich trennen, ohne dass sicherheitsrelevante (Bilder) theoretisch extern erreichbar sind? Im Moment laufen Frontend und Backend in einer Docker Compose. Ist das sinnvoll? Wie trennt man das am Besten?
**Antwort (zusammengefasst & praxisorientiert):**
- Development: Für lokale Entwicklung ist eine gemeinsame `docker-compose`-Umgebung (Frontend + Backend) sinnvoll — HMR, einfache Debugging und Tests.
- Produktion: Trennung empfohlen. Ziel: Frontend öffentlich (statisch/CDN) betreiben, Backend streng begrenzen und Assets (Originale/Previews) niemals ohne Authorisierung öffentlich machen.
Architektur-Optionen (Kurz):
- Single-Server mit `nginx` ReverseProxy (empfohlen, einfach): `nginx` routet `/` zum statischen Frontend und `/api/*` zum Backend; Backend nicht direkt öffentlich.
- Frontend extern (CDN/Netlify) + Backend intern hinter ReverseProxy: Frontend ist skalierbar, Backend nur über Proxy erreichbar; für Bilder: presigned URLs oder BackendProxy verwenden.
- Vollständige Trennung (Backend nur im privaten Netz / VPN): Sehr sicher, aber komplexer (VPN/VPC). Admin-/Moderation nur über LAN/VPN erreichbar.
Wie Bilder sicher halten (Pattern):
- Pattern A — Backendproxied images: Bilder nur auf Backend speichern; Zugriff nur über BackendEndpunkte (prüfen ManagementUUID / Host), keine direkte öffentliche URL.
- Pattern B — Private Object Storage + presigned URLs: Nutze privaten S3/Bucket; generiere kurzlebige presigned URLs nach Auth/Zugriffsprüfung; kombiniere mit CDN (Origin Access).
- Pattern C — CDN + signed URLs für Previews: Nur Previews via CDN mit signed URLs; Originals bleiben intern oder ebenfalls presigned.
Konkrete Maßnahmen (umsetzbar sofort):
1. ReverseProxy (`nginx`) einführen: zwei vhosts (public / internal). Auf public vhost `/api/admin` und `/groups` blockieren; nur `/api/upload` und `/api/manage/:token` erlauben.
2. DockerNetzwerke: Backend in `internal_net` ohne veröffentlichte Ports; `reverse-proxy` hat öffentliche Ports und verbindet zu `backend` intern.
3. HostGate Middleware (Express): `req.isPublic` setzen via `Host`/`X-Forwarded-Host`, serverseitig Routen (Admin/Groups) für public blocken — defense in depth.
4. CORS & RateLimit: CORS auf erlaubte Origins, strengere RateLimits für public Uploads (z. B. 20 Uploads/IP/Stunde) und Captcha prüfen.
5. Logging: AuditLogs erweitern (z. B. `source_host`) um public vs internal Uploads zu unterscheiden.
Beispiel nginxSnippet (konzeptionell):
```
server {
server_name public.example.com;
location / { root /usr/share/nginx/html; try_files $uri /index.html; }
location ~ ^/api/(upload|manage) { proxy_pass http://backend:5001; proxy_set_header Host $host; }
location ~ ^/api/admin { return 403; }
location ~ ^/groups { return 403; }
}
server {
server_name internal.lan.example.com;
location / { proxy_pass http://frontend:3000; }
location /api/ { proxy_pass http://backend:5001; }
}
```
DockerCompose Hinweis (prod): Backend ohne `ports:` veröffentlichen; `reverse-proxy` expose Ports 80/443 und verbindet intern:
```
services:
reverse-proxy:
ports: ["80:80","443:443"]
networks: [public_net, internal_net]
backend:
networks: [internal_net]
# no ports
networks:
internal_net:
internal: true
```
Checklist (schnell umsetzbar)
- [ ] `nginx` reverseproxy hinzufügen
- [ ] BackendPorts entfernen (nur interner Zugriff)
- [ ] vhostRegeln: public vs internal (Admin blockieren auf public)
- [ ] `hostGate` middleware implementieren (Express)
- [ ] CORS, RateLimit, Captcha konfigurieren
- [ ] AuditLog `source_host` ergänzen
Wenn Du möchtest, implementiere ich als nächsten Schritt die `hostGate`Middleware, BeispielnginxVHosts und die `docker-compose`Änderungen als Patch hier im Repository. Sag mir kurz, welche Hostnames (Platzhalter sind OK) und ob Du Frontend lokal im selben Host behalten willst oder extern hosten willst.
## Technische Details & Voraussetzungen
Im Folgenden findest Du eine vertiefte, technische Zusammenfassung der Architekturoptionen, Voraussetzungen und Sicherheitsmaßnahmen — als Entscheidungsgrundlage für die Implementierung des Subdomainabhängigen Verhaltens.
1) Ziel und Sicherheitsprinzip
- Zweck: Subdomainabhängig unterschiedliche UX und APIZugänglichkeit (Public: Upload + Manage-UUID; Intranet: FullFeature).
- Sicherheitsprinzip: Nie ausschließlich auf FrontendSteuerung vertrauen — serverseitige Blockierung ist Pflicht.
2) InfrastrukturVarianten
- Variante A — Single Host + `nginx` ReverseProxy: Einfach, kontrollierbar, Proxy hostet TLS, routet an Backend; Backend nicht direkt erreichbar.
- Variante B — Frontend extern (CDN/Netlify) + Backend intern: Skalierbar; Bilder per presigned URLs oder BackendProxy ausliefern.
- Variante C — Backend nur im privaten Netz/VPN: Höchste Sicherheit, mehr Betriebskomplexität.
3) HostErkennung und Defense in Depth
- Proxy muss `Host` bzw. `X-Forwarded-Host` weiterreichen (`proxy_set_header Host $host`).
- Implementiere serverseitig eine `hostGate`Middleware, die `req.isPublic` bzw. `req.isInternal` setzt und schütze kritische Routen zusätzlich (Admin, Groups Listing, Cleanup).
- Kombiniere ProxyRegeln + Middleware + BearerToken (für Admin) + Firewall für maximale Sicherheit.
4) Speicherung und Auslieferung von Bildern
- Standard: Bilder lokal in `backend/src/data/images` und `.../previews`.
- Pattern A (empfohlen kleinbetrieblich): Backendproxied images — keine direkten öffentlichen Pfade; Backend kontrolliert Zugriffe (UUID, Host).
- Pattern B (Skalierung): Privater ObjectStore (S3compatible) + presigned URLs (TTL kurz) + CDN (Origin Access) für Performance.
- Previews können weniger restriktiv gehandhabt werden (kurze TTLs / signed URLs), Originals sollten restriktiver sein.
5) ManagementUUID (Risiken & Optionen)
- Aktuell: UUIDs permanent gültig bis Löschung (convenience). Risiko: Leak bedeutet Zugriff.
- Optionen: Beibehalten + RateLimit/Audit (empfohlen), oder TTL/Rotation/Optin Passwortschutz (sicherer, schlechtere UX).
6) CORS, CSRF, TLS
- CORS: Nur erlaubte Origins eintragen (public frontend origin(s) und/oder intranet origin).
- CSRF: REST API mit token/UUID im Pfad ist weniger CSRFanfällig, trotzdem sicherheitsbewusst durchführen.
- TLS/HSTS: Pflicht für öffentliche Hosts.
7) RateLimiting und AbuseProtection
- Public Uploads streng limitieren (z. B. 20 uploads/IP/Stunde) + Dateigrößenlimits + MIME/Exif/TypeValidation.
- Optional Captcha für Uploads bei hohem Traffic/Abuse Verdacht.
8) Logging und Monitoring
- Ergänze AuditLogs um `source_host`/`source_type` und `request_origin`.
- Metriken für ratelimit hits, 403s, upload errors, health checks; optional Sentry/Prometheus.
9) Docker/Bereitstellungsempfehlungen
- Dev: `docker/dev/docker-compose.yml` mit exposed ports OK.
- Prod: Backend keinem Hostport aussetzen (`ports:` entfernen). Reverseproxy exponiert 80/443; backend nur im internen DockerNetz.
- Verwende ein `internal` DockerNetzwerk oder separate Netzwerke für Public/Private.
10) nginxproxymanager Hinweise
- Konfiguriere ProxyHosts für public vs. internal mit passenden Headern (`Host`, `X-Forwarded-*`).
- Verwende ProxyRegeln, um `/api/admin` & `/groups` auf public Host zu blocken; teste mit `curl`.
11) DeploymentPrerequisites (konkret)
- DNS für beide Subdomains (public + intranet) vorhanden.
- TLS für public (Let's Encrypt) und internes Zertifikat für LAN.
- `ADMIN_API_KEY` sicher gesetzt, `PUBLIC_HOSTS` / `INTERNAL_HOSTS` konfiguriert.
- Backup/RestorePolicy für DB & images.
12) Entscheidungsfragen / Tradeoffs
- UUID permanent vs TTL: UX vs Security.
- Previews via CDN vs BackendProxy: Performance vs Kontrolle.
- Frontend lokal hinter nginx vs extern gehostet: Einfachheit vs Skalierbarkeit.
13) Prüfbare Akzeptanzkriterien (Beispiele)
- `curl -I https://public.example.com/api/admin/deletion-log` → 403
- Upload via public Host funktioniert (POST to `/api/upload`), Moderation API returns 403.
- Backend nicht per `docker ps`/published port extern erreichbar.
14) Vorschlag: nächste nonimplementierende Schritte
- Definiere endgültig: Public/Internal Hostnames; ManagementUUID Policy (TTL ja/nein); RateLimit Wert; CDN für Previews ja/nein.
- Ich kann danach ein SecurityDesignDokument (nginx rules, env vars, checklist) erstellen oder direkt ImplementierungsPatches liefern.
Bitte bestätige kurz die vier entscheidenden Punkte, damit ich das Design final zuspitze:
- Hosts: welche Subdomains sollen verwendet werden? (z. B. `deinprojekt.meindomain.de`, `deinprojekt.lan.meindomain.de`)
- ManagementUUID extern erlaubt? (Ja/Nein)
- RateLimit für public Uploads? (z. B. `20 uploads/IP/Stunde`)
- Previews via CDN erlaubt? (Ja/Nein)
---
Bitte sag mir, ob ich diese detaillierte Sektion so übernehmen soll (ich habe sie bereits in dieses FeatureRequest eingefügt). Wenn ja, kann ich auf Wunsch noch ein kurzes SecurityDesignPDF oder konkrete nginxSnippetDateien ergänzen.
<!-- Ende Feature Request -->

View File

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

View File

@ -0,0 +1,250 @@
<!--
Feature Request: Server-seitige Session-Authentifizierung für Admin-API
Zielgruppe: Entwickler / KI-Implementierer
-->
1. erstelle ein Branch namens `feature/security` aus dem aktuellen `main` Branch.
2. erstelle eine Datei `FeatureRequests/FEATURE_PLAN-security.md` in der du die Umsetzungsaufgaben dokumentierst (siehe unten) und darin die TODO Liste erstellst und aktuallisierst.
3. Stelle mir Fragen bezüglich der Umsetzung
4. Verstehe, wie bisher im Frontend die UI aufgebaut ist (modular, keine inline css, globale app.css)
5. Implementiere die untenstehenden Aufgaben Schritt für Schritt.
# FEATURE_REQUEST: Security — Server-seitige Sessions für Admin-API
Umsetzungsaufgaben (konkret & eindeutig für KI / Entwickler)
Die folgenden Aufgaben sind Schritt-für-Schritt auszuführen. Jede Aufgabe enthält das gewünschte Ergebnis und minimalen Beispielcode oder Befehle. Die KI/Entwickler sollen die Änderungen als Code-Patches anlegen, Tests hinzufügen und die Dokumentation aktualisieren.
1) Session-Store & Session-Konfiguration
- Ziel: Server-seitige Sessions für Admin-Login verfügbar machen.
- Schritte:
- Installiere Packages: `npm install express-session connect-sqlite3 --save` (Backend).
- In `backend/src/server.js` (oder Entrypoint) konfiguriere `express-session` mit `connect-sqlite3`:
```js
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
app.use(session({
store: new SQLiteStore({ db: 'sessions.sqlite' }),
secret: process.env.ADMIN_SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', maxAge: 8*60*60*1000 }
}));
```
- Abnahme: Session-Cookie (`sid`) wird gesetzt nach Login, cookie-Flags korrekt.
2) Login-Endpoint (Admin)
- Ziel: Admin kann sich mit Benutzername/Passwort anmelden; Backend erstellt Session.
- Schritte:
- Füge `POST /auth/login` hinzu, prüft Credentials (z. B. gegen environment-stored admin user/pass oder htpasswd), legt `req.session.user = { role: 'admin' }` an und `req.session.csrfToken = randomHex()` an.
- Rückgabe: 200 OK. Cookie wird automatisch gesetzt (`credentials: 'include'` vom Frontend).
- Abnahme: Nach `POST /auth/login` existiert `req.session.user` und `req.session.csrfToken`.
3) CSRF-Endpoint + Middleware
- Ziel: Session-gebundenen CSRF-Token ausgeben und Requests schützen.
- Schritte:
- Endpoint `GET /auth/csrf-token` gibt `{ csrfToken: req.session.csrfToken }` zurück (nur wenn eingeloggt).
- Middleware `requireCsrf` prüft `req.headers['x-csrf-token'] === req.session.csrfToken` für state-changing Methoden.
- Abnahme: state-changing Admin-Requests ohne oder mit falschem `X-CSRF-Token` bekommen `403`.
4) Backend-Auth-Middleware für Admin-API
- Ziel: Alle `/api/admin/*` Endpoints prüfen Session statt Client-Token.
- Schritte:
- Ersetze oder erweitere bestehende Admin-Auth-Middleware (`middlewares/auth.js`) so, dass sie `req.session.user && req.session.user.role === 'admin'` prüft; falls nicht gesetzt → `403`.
- Abnahme: `GET /api/admin/*` ohne Session → `403`; mit gültiger Session → durchgelassen.
5) Frontend-Änderungen (adminApi)
- Ziel: Frontend sendet keine Admin-Bearer-Tokens mehr; verwendet Cookie-Session + CSRF-Header.
- Schritte:
- Entferne in `frontend/src/services/adminApi.js` die Abhängigkeit von `process.env.REACT_APP_ADMIN_API_KEY`.
- Passe `adminFetch`/`adminRequest` an: bei Requests setze `credentials: 'include'` und füge `X-CSRF-Token` Header (Token bezieht Frontend über `GET /auth/csrf-token` nach Login).
- Dokumentiere in `frontend/README` oder Code-Kommentar, dass Admin-UI nach Login `fetch('/auth/csrf-token', { credentials: 'include' })` aufruft.
- Abnahme: `adminApi.js` sendet keine Bearer-Header; admin Requests beinhalten `credentials: 'include'` und `X-CSRF-Token`.
6) Entfernen von Admin-Key aus Frontend Build/Compose/Dockerfile
- Ziel: Keine Weitergabe von `ADMIN_API_KEY` an `frontend` und kein Kopieren sensibler `.env` in Frontend-Image.
- Schritte:
- Entferne Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` aus `docker/prod/docker-compose.yml`.
- Entferne `COPY docker/prod/frontend/config/.env ./.env` aus `docker/prod/frontend/Dockerfile` oder stelle sicher, dass diese Datei ausschließlich non-sensitive Keys enthält.
- Dokumentiere in `FeatureRequests/FEATURE_REQUEST-security.md` welche Keys im runtime-`env.sh` erlaubt sind (z. B. `API_URL`, `APP_VERSION`).
- Abnahme: `docker-compose` enthält keine Übergabe an `frontend`; Build und Image enthalten keine Production-Secrets.
7) Secrets-Handling / Deployment
- Ziel: Secrets nur in Backend-Umgebung bereitstellen.
- Schritte:
- Setze `ADMIN_API_KEY` und `ADMIN_SESSION_SECRET` in CI/CD Secrets oder Docker Secrets und referenziere sie nur im `backend` Service.
- Beispiel-Dokumentation für CI: wie man Secret in GitLab/GitHub Actions setzt und an Container übergibt.
- Abnahme: Secrets sind nicht in Repo/Images; `docker inspect` der frontend-Container zeigt keinen Admin-Key.
8) Tests & CI-Checks
- Ziel: Automatisierte Verifikation der Sicherheitsregeln.
- Schritte:
- Integrationstest 1: `GET /api/admin/some` ohne Session → expect 403.
- Integrationstest 2: `POST /auth/login` with admin credentials → expect Set-Cookie; then `GET /auth/csrf-token` → receive token; then `POST /api/admin/action` with `X-CSRF-Token` → expect 200.
- Build-scan-Check: CI Schritt `rg REACT_APP_ADMIN_API_KEY build/ || true` fails if found.
- Abnahme: Tests grün; CI verweigert Merge wenn Build enthält Admin-Key.
9) Key-Leak Reaktion (konkrete Anweisungen)
- Ziel: Falls ein Admin-Key geleakt wurde, sichere, koordinierte Rotation.
- Schritte:
- Scannen: `trufflehog --regex --entropy=True .` oder `git-secrets scan`.
- Entfernen: `git-filter-repo --replace-text passwords.txt` oder `bfg --replace-text passwords.txt` (siehe docs).
- Rotation: Erzeuge neuen Key (openssl rand -hex 32), update CI secret, redeploy Backend.
- Hinweis: History-Rewrite ist invasiv; kommuniziere mit Team und informiere Contributors.
10) Dokumentation
- Ziel: Abschlussdokumentation aktualisiert.
- Schritte:
- Ergänze `AUTHENTICATION.md` um Login/Session/CSRF-Flow und Secret-Handling.
- Ergänze `FeatureRequests/FEATURE_REQUEST-security.md` mit Implementations-Links (Patches/PRs).
11) MIGRATION-GUIDE Anpassung (unbedingt)
- Ziel: Die `frontend/MIGRATION-GUIDE.md` spiegelt nicht mehr den sicheren Produktions-Workflow. Sie muss aktualisiert werden, damit Entwickler/KI keine unsicheren Anweisungen (Admin-Key im Frontend) ausführen.
- Aktueller Stand (zu prüfen): Die MIGRATION-GUIDE enthält Anweisungen, `REACT_APP_ADMIN_API_KEY` in `frontend/.env` zu setzen und dieselbe Variable an `frontend` im `docker-compose.yml` weiterzugeben. Dies steht im direkten Widerspruch zur hier geforderten serverseitigen Session-Lösung.
- Erforderliche Änderungen in `frontend/MIGRATION-GUIDE.md` (konkret):
- Entferne oder ersetze alle Anweisungen, die `REACT_APP_ADMIN_API_KEY` in Frontend `.env` oder Build-Umgebungen für Production setzen.
- Ersetze Fetch-/Axios-Beispiele, die `Authorization: Bearer ${process.env.REACT_APP_ADMIN_API_KEY}` setzen, durch die neue Anleitung: Login → `GET /auth/csrf-token``fetch(..., { credentials: 'include', headers: { 'X-CSRF-Token': csrfToken } })`.
- Passe das Docker-Beispiel an: `ADMIN_API_KEY` darf nur dem `backend`-Service übergeben werden; entferne die Weitergabe an `frontend` (Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}`).
- Ersetze lokale Testanweisungen, die Frontend mit `REACT_APP_ADMIN_API_KEY` starten, durch Login-/Session-Testschritte (siehe Tasks 2/3/8).
- Ergänze Hinweis zur CI/Build-Scan-Prüfung: CI muss prüfen, dass gebaute `build/` keine Admin-Key-Strings enthält.
- Abnahme: `frontend/MIGRATION-GUIDE.md` enthält keine Production-Anweisungen, die Admin-Secrets ins Frontend bringen; stattdessen ist der Session-Flow dokumentiert und verlinkt.
Hinweis für die Implementierung
- Ergänze in `FeatureRequests/FEATURE_REQUEST-security.md` einen Link/Verweis zur überarbeiteten MIGRATION-GUIDE-Version in der PR/Release-Notes, damit Reviewer die Änderung nachvollziehen können.
Rolle der implementierenden KI/Dev
- Erzeuge konkrete Code-Patches, führe lokale Tests aus, öffne PR mit Änderungen und Tests.
- Stelle sicher, dass alle Abnahme-Kriterien (oben) automatisiert oder manuell prüfbar sind.
Mit diesen Aufgaben sind die vorher offenen Fragen in eindeutige, ausführbare Schritte übersetzt. Bitte bestätige, welche Aufgaben ich automatisch umsetzen soll (z. B. `1` = Compose/Docker-Änderungen; `2` = Frontend `adminApi.js` Patch; `3` = Backend Session+CSRF minimal-Implementierung; oder `all`).
Hintergrund (Ist-Stand)
- Aktuell existieren folgende sicherheitskritische Zustände im Repository:
- `frontend/.env` enthält `REACT_APP_ADMIN_API_KEY` in der Arbeitskopie (lokal). Die Datei ist in `.gitignore` und wird nicht ins Git-Repository getrackt, ist aber sensibel und darf nicht in Builds/Images gelangen.
- `docker/prod/docker-compose.yml` injiziert `REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` in den `frontend`-Service — der Key kann so in den gebauten Frontend-Bundles landen.
- `frontend/src/services/adminApi.js` liest `process.env.REACT_APP_ADMIN_API_KEY` und sendet den Bearer-Token clientseitig mit Admin-Requests.
- Das Production-Frontend-Dockerfile kopiert `docker/prod/frontend/config/.env` in das Laufzeit-Image und führt zur Startzeit ein `env.sh` aus, das `env-config.js` erzeugt (`window._env_`), was sensible Werte im Browser verfügbar machen kann, falls sie in `.env` landen.
- Die Moderation-Weboberfläche ist zusätzlich durch `htpasswd`/nginx HTTP Basic Auth geschützt — das schützt das UI, aber nicht die API-Endpoints ausreichend.
Problemstellung (warum es ein Problem ist)
- Ein im Frontend sichtbarer Admin-Key ist öffentlich und ermöglicht Missbrauch (API-Calls mit Admin-Rechten von jedem Browser).
- Das serverseitige Secret `ADMIN_API_KEY` wird derzeit in Artefakte/Images injiziert und kann geleakt werden.
- HTTP Basic Auth vor der UI ist nützlich, aber kein Ersatz für serverseitige API-Authentifizierung; API-Endpunkte müssen eigenständig prüfen.
Ziel (Soll-Stand, aus Kundensicht)
- Admin-Funktionen sind nur nach sicherer Anmeldung erreichbar.
- Der geheime Admin-Key verbleibt ausschließlich auf dem Server/Backend und wird nicht in Frontend-Code, Images oder öffentlich zugängliche Dateien geschrieben.
- Frontend kommuniziert nach Anmeldung mit dem Backend, ohne je den Admin-Key im Browser zu speichern.
Anforderungen (aus Sicht des Auftraggebers, umsetzbar durch eine KI)
- Authentifizierung:
- Einführung eines serverseitigen Login-Flows für Admins (Session-Cookies, HttpOnly, Secure, SameSite).
- Nach erfolgreicher Anmeldung erhält der Admin-Browser ein HttpOnly-Cookie; dieses Cookie erlaubt Zugriff auf geschützte `/api/admin/*`-Endpoints.
- Backend validiert alle `/api/admin/*`-Requests anhand der Session; nur dann wird mit dem internen `ADMIN_API_KEY` gearbeitet.
- Secrets & Build:
- Keine Secrets (z. B. `ADMIN_API_KEY`) im Frontend-Quellcode, in `frontend/.env`, in `env-config.js` oder in gebauten Bundles.
- `docker/prod/docker-compose.yml` darf `ADMIN_API_KEY` nur dem `backend`-Service bereitstellen; keine Weitergabe an `frontend`.
- `Dockerfile` des Frontends darf keine Produktion-`.env` kopieren, die Secrets enthält.
- Betrieb & Infrastruktur:
- Bestehende `htpasswd`-Absicherung der Admin-UI kann beibehalten werden als zusätzliche Hürde, ist aber nicht die einzige Schutzmaßnahme.
- Empfehlung: `ADMIN_API_KEY` über sichere Secret-Mechanismen bereitstellen (CI/CD secret store, Docker Secrets, Swarm/K8s Secrets) — dies ist ein Hinweis, keine Pflichtanweisung.
Akzeptanzkriterien (klar messbar, für Tests durch eine KI/Dev)
- Funktional:
- Unauthentifizierte Requests an `/api/admin/*` erhalten `403 Forbidden`.
- Admin-Login-Endpoint existiert und setzt ein HttpOnly-Cookie; angemeldete Admins erreichen `/api/admin/*` erfolgreich.
- Artefakte / Repo:
- `frontend`-Bundle (der gebaute `build/`-Ordner) enthält nicht den Wert von `ADMIN_API_KEY` (automatischer Scan: kein Vorkommen des Key-Strings).
- `frontend/.env` enthält keine `REACT_APP_ADMIN_API_KEY`-Zeile in Produktion; `docker/prod/docker-compose.yml` enthält keine Weitergabe des Keys an `frontend`.
- Sicherheit & Ops:
- Dokumentation: In `AUTHENTICATION.md` und in dieser Feature-Request-Datei wird der neue Login-Flow und Hinweis zum Secret-Handling vermerkt.
- Dokumentation: In `AUTHENTICATION.md` und in dieser Feature-Request-Datei wird der neue Login-Flow und Hinweis zum Secret-Handling vermerkt.
- Falls ein Key im Git-Verlauf existierte, ist die Rotation des Admin-Keys als Handlungsempfehlung dokumentiert.
- Falls `frontend/.env` oder ein Admin-Key jemals in das Repository gelangt ist: Scannt die Git-History und entfernt das Secret aus der History, danach rotiert den Key. Empfohlene Tools/Schritte (kurz):
- Finden: `git log --all -S 'part-of-key'` oder `git grep -n "REACT_APP_ADMIN_API_KEY" $(git rev-list --all)` oder nutzen `truffleHog`/`git-secrets`.
- Entfernen aus History: `git-filter-repo` oder `bfg-repo-cleaner` (z.B. `bfg --replace-text passwords.txt --no-blob-protection`) — danach Force-Push in ein neues Remote (Achtung: Auswirkungen auf Contributors).
- Key-Rotation: Erzeuge neuen `ADMIN_API_KEY`, setze ihn in der sicheren Backend-Umgebung (CI/CD secrets / Docker secret), redeploye Backend.
- Hinweis: Diese Schritte sind invasiv für die Git-History; koordinieren mit Team bevor Ausführung.
Nicht-funktionale Anforderungen
- Use Session-Cookies: Cookies müssen `HttpOnly`, `Secure` und `SameSite=Strict` (oder Lax falls nötig) gesetzt werden.
- CSRF-Schutz: Bei Cookie-basierten Sessions muss ein CSRF-Schutzmechanismus vorhanden sein (z. B. double-submit-token oder CSRF-Header). Hinweis: CSRF-Mechanik ist zu implementieren, aber detaillierte Schritte sind nicht Teil dieses Requests.
- Kompatibilität: Änderungen dürfen Entwickler-Workflows nicht unnötig blockieren; Dev-Mode-Patterns (runtime `env-config.js` in `docker/dev`) können bestehen bleiben, jedoch klar getrennt von Prod.
Hinweise für die implementierende KI / das Dev-Team (kontextbezogen)
- Aktueller Code-Pfade von Relevanz:
- `frontend/src/services/adminApi.js` — liest aktuell `process.env.REACT_APP_ADMIN_API_KEY` und setzt den Bearer-Token clientseitig.
- `frontend/.env` — enthält aktuell `REACT_APP_ADMIN_API_KEY`.
- `docker/prod/docker-compose.yml` — injiziert `REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` in `frontend`.
- `docker/prod/frontend/Dockerfile` — kopiert `docker/prod/frontend/config/.env` in das Image und führt `env.sh` aus, das `env-config.js` erzeugt (`window._env_`).
- `docker/prod/frontend/config/env.sh` — generiert zur Laufzeit `env-config.js` aus `.env`.
- `docker/prod/frontend/config/htpasswd` — existierender Schutz der Admin-UI via nginx.
- Erwartung an eine KI-Implementierung:
- Verstehe die Codebasis (insbesondere `frontend/src/*` und `backend/src/*`) und identifiziere alle Stellen, die `REACT_APP_ADMIN_API_KEY` oder `ADMIN_API_KEY` verwenden oder weiterreichen.
- Entferne clientseitige Verwendung des Admin-Keys; ersetze Aufrufe an Admin-API so, dass sie serverseitig autorisiert werden (Session-Check).
- Verifiziere durch automatische Tests (Integrationstest oder API-Call) dass `/api/admin/*` ohne Session abgewiesen wird und mit Session funktioniert.
Was der Auftraggeber (Ich) erwartet — kurz und klar
- Die Admin-Funktionen sind nur nach Anmeldung verfügbar.
- Keine Admin-Secrets gelangen in Frontend-Bundles, Images oder öffentlich zugängliche Dateien.
- Der existierende `htpasswd`-Schutz darf bestehen bleiben, ist aber nicht alleinige Sicherheitsmaßnahme.
Abnahmekriterien (für das Review durch den Auftraggeber)
- Manuelle Überprüfung: Versuch, Admin-Endpoints ohne Login aufzurufen → `403`.
- Build-Review: gebaute Frontend-Dateien enthalten keinen Admin-Key.
- Dokumentation aktualisiert (`AUTHENTICATION.md` weist auf neue Session-Flow hin).
Offene Fragen / Optionen (für Entwickler/KI) — Empfehlung und Umsetzungsdetails
- Session-Store (empfohlen: SQLite für Single-Host, Redis für Skalierung)
- Empfehlung für diese App: **SQLite / file-basierter Session-Store** (einfach zu betreiben, keine zusätzliche Infrastruktur).
- Umsetzung (Express): benutze `express-session` + `connect-sqlite3` (oder `better-sqlite3` backend). Konfiguration:
- Session-Cookie: `HttpOnly`, `Secure` (Prod), `SameSite=Strict` (oder `Lax` wenn externe callbacks nötig), `maxAge` angemessen (z. B. 8h).
- Session-Secret aus sicherer Quelle (Backend `ADMIN_SESSION_SECRET`), nicht im Repo.
- Skalierung: falls Cluster/Multiple hosts geplant, wechsle zu **Redis** (z.B. `connect-redis`) und setze Redis via Docker/K8s Secret.
- CSRF-Mechanik (empfohlen: session-bound CSRF token + Header)
- Empfehlung: Implementiere einen session-gebundenen CSRF-Token. Ablauf:
1. Bei Login: generiere `req.session.csrfToken = randomHex()` auf dem Server.
2. Exponiere Endpoint `GET /auth/csrf-token` (nur für eingeloggte Sessions), der das Token im JSON zurückgibt.
3. Frontend ruft `/auth/csrf-token` nach Login (`credentials: 'include'`) und speichert Token im JS-Scope.
4. Bei state-changing Requests sendet Frontend `X-CSRF-Token: <token>` Header.
5. Server-Middleware vergleicht Header mit `req.session.csrfToken` und verwirft bei Mismatch (403).
- Vorteil: HttpOnly-Session-Cookie bleibt geschützt; Token ist an Session gebunden.
- Alternative (schnell): Double-submit cookie (weniger robust, Token in non-HttpOnly cookie + Header); nur als kurzfristige Übergangslösung.
- Entfernen von Admin-Key aus Frontend/Build (konkrete Änderungen)
- `frontend/src/services/adminApi.js`: entferne Nutzung von `process.env.REACT_APP_ADMIN_API_KEY`. Passe `adminRequest` so an, dass `credentials: 'include'` verwendet wird und kein Bearer-Token gesetzt wird.
- `docker/prod/docker-compose.yml`: lösche die Zeile `- REACT_APP_ADMIN_API_KEY=${ADMIN_API_KEY}` unter `frontend`.
- `docker/prod/frontend/Dockerfile`: entferne `COPY docker/prod/frontend/config/.env ./.env` (oder stelle sicher, dass die Datei keine Secrets enthält). Vermeide Prod `.env` Kopie in Image.
- `docker/prod/frontend/config/env.sh`: darf nur non-sensitive Werte (z. B. `API_URL`, `APP_VERSION`) schreiben; dokumentiere welche Keys erlaubt sind.
- Secrets-Delivery / Deployment
- Backend-Secret `ADMIN_API_KEY` und `ADMIN_SESSION_SECRET` via CI/CD Secret Store oder Docker Secrets bereitstellen.
- Beispiel (Docker secret): erstelle Secret im Swarm/K8s und referenziere es nur im `backend`-Service.
- Tests / Abnahme (automatisierbar)
- Integrationstest 1: `GET /api/admin/*` ohne Session → `403`.
- Integrationstest 2: Login-Flow → setze Session-Cookie → `POST /api/admin/*` mit gültigem `X-CSRF-Token``200`.
- Build-Scan: nach Build `grep -R "<part-of-admin-key>" build/` oder `rg REACT_APP_ADMIN_API_KEY build/` → kein Treffer.
- Key-Leak Reaktion
- Falls Key geleakt: sofort Key-Rotation, dokumentiere wie (generate new key, update backend secret, redeploy), und führe Git-History-Check wie in Abschnitt oben durch.
- Operationale Hinweise
- Behalte `htpasswd`-Schutz für Admin-UI als zusätzliche Zugangskontrolle, nicht als einzige Schutzmaßnahme.
- Dokumentiere alle Änderungen in `AUTHENTICATION.md` und in Release-Notes.
Diese Empfehlungen sind aus Sicht einer sicheren, pragmatischen Umsetzung gewählt: sie minimieren Infrastruktur-Overhead (SQLite) und bieten klare, testbare CSRF/SessionMechaniken. Eine implementierende KI soll die obenstehenden Punkte in konkreten CodePatches umsetzen und automatisierte Tests hinzufügen.
Kontakt / Kontext
- Repo: Project-Image-Uploader — bestehende Struktur `docker/prod`, `frontend/`, `backend/` beachten.
- Hinweis: Diese Feature-Request-Datei benennt Missstände und Anforderungen; sie erwartet, dass ein Entwickler oder eine KI die Implementierung auf Code-Ebene vornimmt.
---

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

@ -0,0 +1,76 @@
# Feature Testplan: Admin-Session-Sicherheit
## Ziel
Sicherstellen, dass die neue serverseitige Admin-Authentifizierung (Session + CSRF) korrekt funktioniert, keine Secrets mehr im Frontend landen und bestehende Upload-/Management-Flows weiterhin laufen.
## Voraussetzungen
- `ADMIN_SESSION_SECRET` ist gesetzt bei Dev in `docker/dev/backend/config/.env`, bei Prod in `docker/prod/backend/.env`. Wert per `openssl rand -hex 32` generieren.
- Docker-Stack läuft (`./dev.sh` bzw. `docker compose -f docker/dev/docker-compose.yml up -d` für Dev oder `docker compose -f docker/prod/docker-compose.yml up -d` für Prod).
- Browser-Cookies gelöscht bzw. neue Session (Inkognito) verwenden.
- `curl` und `jq` lokal verfügbar (CLI-Aufrufe), Build/Tests laufen innerhalb der Docker-Container.
## Testumgebungen
| Umgebung | Zweck |
|----------|-------|
| `docker/dev` (localhost) | Haupt-Testumgebung, schnelle Iteration |
| Backend-Jest Tests | Regression für API-/Auth-Layer |
| Frontend Build (`docker compose exec frontend-dev npm run build`) | Sicherstellen, dass keine Secrets im Bundle landen |
## Testfälle
### 1. Initiales Admin-Setup
1. `curl -c cookies.txt http://localhost:5001/auth/setup/status``needsSetup` prüfen.
2. Falls `true`: `curl -X POST -H "Content-Type: application/json" -c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' \
http://localhost:5001/auth/setup/initial-admin` → `success: true`, Cookie gesetzt.
3. `curl -b cookies.txt http://localhost:5001/auth/setup/status``needsSetup:false`, `hasSession:true`.
4. `curl -b cookies.txt http://localhost:5001/auth/logout` → 204, Cookie weg.
### 2. Login & CSRF (Backend-Sicht)
1. `curl -X POST -H "Content-Type: application/json" -c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' http://localhost:5001/auth/login`.
2. `CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')`.
3. `curl -b cookies.txt -H "X-CSRF-Token: $CSRF" http://localhost:5001/api/admin/groups` → 200.
4. Fehlerfälle prüfen:
- Ohne Cookie → 403 `{ reason: 'SESSION_REQUIRED' }`.
- Mit Cookie aber ohne Token → 403 `{ reason: 'CSRF_INVALID' }`.
- Mit falschem Token → 403 `{ reason: 'CSRF_INVALID' }`.
### 3. Moderations-UI (Frontend)
1. Browser auf `http://localhost:3000/moderation` → Login oder Setup-Wizard erscheint.
2. Wizard ausfüllen (nur beim ersten Start).
3. Normales Login durchführen (korrekte & falsche Credentials testen).
4. Nach Login folgende Aktionen validieren (Network-Tab kontrollieren: Requests senden Cookies + `X-CSRF-Token`):
- Gruppenliste lädt.
- Gruppe approve/reject.
- Cleanup-Preview/-Trigger (falls Daten vorhanden).
- Social-Media-Einstellungen laden/speichern.
5. Logout in der UI → Redirect zum Login, erneutes Laden zeigt Login.
6. Browser-Refresh nach Logout → kein Zugriff auf Admin-Daten (sollte Login anzeigen).
### 4. Regression Upload & Management
1. Normales Upload-Formular durchspielen (`/`): Gruppe hochladen.
2. Management-Link (`/manage/:token`) öffnen, Consents ändern, Bilder verwalten.
3. Sicherstellen, dass neue Session-Mechanik nichts davon beeinflusst.
### 5. Öffentliche APIs
1. `curl http://localhost:5001/api/social-media/platforms` → weiterhin öffentlich verfügbar.
2. Slideshow & Gruppenübersicht im Frontend testen (`/slideshow`, `/groups`).
### 6. Bundle-/Secret-Prüfung
1. Dev-Stack: `docker compose -f docker/dev/docker-compose.yml exec frontend-dev npm run build` (Prod analog mit `docker/prod`).
2. `docker compose -f docker/dev/docker-compose.yml exec frontend-dev sh -c "grep -R 'ADMIN' build/"` → keine geheimen Variablen (nur Dokumentationsstrings erlaubt).
3. Falls doch Treffer: Build abbrechen und Ursache analysieren.
### 7. Automatisierte Tests
1. Backend: `docker compose -f docker/dev/docker-compose.yml exec backend-dev npm test` (neue Auth-Tests müssen grün sein).
2. Optional: `docker compose -f docker/dev/docker-compose.yml exec frontend-dev npm test` oder vorhandene E2E-Suite per Container laufen lassen.
### 8. CI/Monitoring Checks
- Pipeline-Schritt hinzunehmen, der `curl`-Smoke-Test (Login + `GET /api/admin/groups`) fährt.
- Optionaler Script-Check, der das Frontend-Bundle auf Secrets scannt.
## Testabschluss
- Alle oben genannten Schritte erfolgreich? → Feature gilt als verifiziert.
- Offene Findings dokumentieren in `FeatureRequests/FEATURE_PLAN-security.md` (Status + Follow-up).
- Nach Freigabe: Reviewer informieren, Deploy-Plan (z. B. neue Session-Secret-Verteilung) abstimmen.

View File

@ -1,5 +1,23 @@
# Development Setup
## ⚠️ Wichtige Hinweise für Frontend-Entwickler
### 🔴 BREAKING CHANGES - API-Umstrukturierung (November 2025)
Im Rahmen der OpenAPI-Auto-Generation wurden **massive Änderungen** an der API-Struktur vorgenommen:
- **Authentication**: Admin-Endpoints laufen jetzt über serverseitige Sessions + CSRF Tokens
- **Route-Struktur**: Einige Pfade haben sich geändert (Single Source of Truth: `routeMappings.js`)
- **Error Handling**: Neue HTTP-Status-Codes (403 für Auth-Fehler)
**📖 Siehe:**
- **`frontend/MIGRATION-GUIDE.md`** - Detaillierte Migrations-Anleitung für Frontend
- **`backend/src/routes/README.md`** - Vollständige API-Route-Dokumentation
- **`AUTHENTICATION.md`** - Auth-System-Setup und Verwendung
---
## Schnellstart
### Starten (Development Environment)
@ -15,8 +33,9 @@ docker compose -f docker/dev/docker-compose.yml up -d
### Zugriff
- **Frontend**: http://localhost:3000 (Hot Module Reloading aktiv)
- **Backend**: http://localhost:5001 (API)
- **API Documentation**: http://localhost:5001/api/docs/ (Swagger UI, nur in Development verfügbar)
- **Slideshow**: http://localhost:3000/slideshow
- **Moderation**: http://localhost:3000/moderation (Entwicklung: ohne Auth)
- **Moderation**: http://localhost:3000/moderation (Login über Admin Session)
### Logs verfolgen
```bash
@ -30,6 +49,110 @@ docker compose -f docker/dev/docker-compose.yml logs -f frontend-dev
docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
```
## API-Entwicklung
### ⚠️ BREAKING CHANGES - Frontend Migration erforderlich
**Massive API-Änderungen im November 2025:**
- Session + CSRF Authentication für alle Admin-Endpoints
- Route-Pfade umstrukturiert (siehe `routeMappings.js`)
- Neue Error-Response-Formate
**📖 Frontend Migration Guide**: `frontend/MIGRATION-GUIDE.md`
### Route-Struktur
Die API verwendet eine **Single Source of Truth** für Route-Mappings:
📄 **`backend/src/routes/routeMappings.js`** - Zentrale Route-Konfiguration
Siehe auch: **`backend/src/routes/README.md`** für vollständige API-Übersicht
**Wichtige Route-Gruppen:**
- `/api/upload`, `/api/download` - Öffentliche Upload/Download-Endpoints
- `/api/manage/:token` - Self-Service Management Portal (UUID-Token)
- `/api/admin/*` - Admin-Endpoints (Session + CSRF Authentication)
- `/api/system/migration/*` - Datenbank-Migrationen
**⚠️ Express Route-Reihenfolge beachten:**
Router mit spezifischen Routes **vor** generischen Routes mounten!
```javascript
// ✅ RICHTIG: Spezifisch vor generisch
{ router: 'consent', prefix: '/api/admin' }, // /groups/by-consent
{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId
// ❌ FALSCH: Generisch fängt alles ab
{ router: 'admin', prefix: '/api/admin' }, // /groups/:groupId matched auf 'by-consent'!
{ router: 'consent', prefix: '/api/admin' }, // Wird nie erreicht
```
### Authentication
**Zwei Auth-Systeme parallel:**
1. **Admin API (Session + CSRF)**:
```bash
# .env konfigurieren:
ADMIN_SESSION_SECRET=$(openssl rand -hex 32)
# Initialen Admin anlegen (falls benötigt)
curl -c cookies.txt http://localhost:5001/auth/setup/status
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' \
http://localhost:5001/auth/setup/initial-admin
# Login + CSRF Token holen
curl -X POST -H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"username":"admin","password":"SuperSicher123"}' \
http://localhost:5001/auth/login
CSRF=$(curl -sb cookies.txt http://localhost:5001/auth/csrf-token | jq -r '.csrfToken')
# Authentifizierter Admin-Request
curl -b cookies.txt -H "X-CSRF-Token: $CSRF" \
http://localhost:5001/api/admin/groups
```
2. **Management Portal (UUID Token)**:
User, die Bilder hochladen, erhalten automatisch einen UUID-Token für das Self-Service Management Portal.
Über diesen Token / Link können sie ihre hochgeladenen Gruppen verwalten:
```bash
# Automatisch beim Upload generiert
GET /api/manage/550e8400-e29b-41d4-a716-446655440000
```
📖 **Vollständige Doku**: `AUTHENTICATION.md`
#### Admin-Hinweise: Logout & neue Nutzer
- **Logout:** Der Moderationsbereich enthält jetzt einen Logout-Button (Icon in der Kopfzeile). Klick → `POST /auth/logout` → Session beendet, Login erscheint erneut. Für Skripte kannst du weiterhin `curl -b cookies.txt -X POST http://localhost:5001/auth/logout` verwenden.
- **Weiterer Admin:** Verwende das neue API-basierte Skript `./scripts/create_admin_user.sh --server http://localhost:5001 --username zweiteradmin --password 'SuperPasswort123!' [--admin-user bestehend --admin-password ... --role ... --require-password-change]`. Das Skript erledigt Login, CSRF, Duplikats-Check und legt zusätzliche Admins über `/api/admin/users` an (Fallback: `backend/src/scripts/createAdminUser.js`).
### OpenAPI-Spezifikation
Die OpenAPI-Spezifikation wird **automatisch beim Backend-Start** generiert:
```bash
# Generiert: backend/docs/openapi.json
# Swagger UI: http://localhost:5001/api/docs/
# Manuelle Generierung:
cd backend
node src/generate-openapi.js
```
**Swagger-Annotationen in Routes:**
```javascript
router.get('/example', async (req, res) => {
/*
#swagger.tags = ['Example']
#swagger.summary = 'Get example data'
#swagger.responses[200] = { description: 'Success' }
*/
});
```
## Entwicklung
### Frontend-Entwicklung
@ -50,9 +173,12 @@ docker compose -f docker/dev/docker-compose.yml logs -f backend-dev
- Environment: `NODE_ENV=development`
**Wichtige Module:**
- `routes/routeMappings.js` - Single Source of Truth für Route-Konfiguration
- `repositories/GroupRepository.js` - Consent-Management & CRUD
- `repositories/SocialMediaRepository.js` - Plattform- & Consent-Verwaltung
- `routes/batchUpload.js` - Upload mit Consent-Validierung
- `middlewares/session.js` - Express-Session + SQLite Store
- `middlewares/auth.js` - Admin Session-Guard & CSRF-Pflicht
- `database/DatabaseManager.js` - Automatische Migrationen
- `services/GroupCleanupService.js` - 7-Tage-Cleanup-Logik
@ -95,6 +221,63 @@ docker compose -f docker/dev/docker-compose.yml logs backend-dev | grep -i migra
## Testing
### Automatisierte Tests
Das Backend verfügt über eine umfassende Test-Suite mit 45 Tests:
```bash
# Alle Tests ausführen:
cd backend
npm test
# Einzelne Test-Suite:
npm test -- tests/api/admin.test.js
# Mit Coverage-Report:
npm test -- --coverage
# Watch-Mode (während Entwicklung):
npm test -- --watch
```
**Test-Struktur:**
- `tests/unit/` - Unit-Tests (z.B. Auth-Middleware)
- `tests/api/` - Integration-Tests (API-Endpoints)
- `tests/setup.js` - Globale Test-Konfiguration
- `tests/testServer.js` - Test-Server-Helper
**Test-Features:**
- Jest + Supertest Framework
- In-Memory SQLite Database (isoliert)
- Singleton Server Pattern (schnell)
- 100% Test-Success-Rate (45/45 passing)
- ~10 Sekunden Ausführungszeit
- Coverage: 26% Statements, 15% Branches
**Test-Umgebung:**
- Verwendet `/tmp/test-image-uploader/` für Upload-Tests
- Eigene Datenbank `:memory:` (kein Konflikt mit Dev-DB)
- Environment: `NODE_ENV=test`
- Automatisches Cleanup nach Test-Run
**Neue Tests hinzufügen:**
```javascript
// tests/api/example.test.js
const { getRequest } = require('../testServer');
describe('Example API', () => {
it('should return 200', async () => {
const response = await getRequest()
.get('/api/example')
.expect(200);
expect(response.body).toHaveProperty('data');
});
});
```
### Manuelles Testing
### Consent-System testen
```bash
# 1. Upload mit und ohne Workshop-Consent
@ -119,6 +302,35 @@ docker compose -f docker/dev/docker-compose.yml logs backend-dev | grep -i migra
# 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
```bash
@ -247,6 +459,237 @@ git commit -m "feat: Add new feature"
git push origin feature/my-feature
```
### Git Hook (optional Absicherung)
Standard-Deployments sollten `ADMIN_SESSION_COOKIE_SECURE=true` behalten, damit das Session-Cookie nur über HTTPS übertragen wird.
Das bereitgestellte Pre-Commit-Hook stellt sicher, dass der Wert in `docker/prod/docker-compose.yml` automatisch auf `true` zurückgesetzt wird, falls er versehentlich verändert wurde (z.B. nach einem Test auf HTTP-only Hardware):
```bash
ln -s ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
```
Nach der Installation aktualisiert der Hook die Datei bei Bedarf und staged sie direkt.
Für lokale HTTP-Lab-Deployments nutze eine separate (gitignorierte) `docker-compose.override.yml`, um `ADMIN_SESSION_COOKIE_SECURE=false` nur zur Laufzeit zu setzen. Entfernen kannst du den Hook jederzeit über `rm .git/hooks/pre-commit`.
## Host-Separation Testing (Public/Internal Hosts)
Die Applikation unterstützt eine Public/Internal Host-Separation für die Produktion. Lokal kann dies mit /etc/hosts-Einträgen getestet werden.
### Schnellstart: Lokales Testing mit /etc/hosts
**1. Hosts-Datei bearbeiten:**
**Linux / Mac:**
```bash
sudo nano /etc/hosts
```
**Windows (als Administrator):**
1. Notepad öffnen (als Administrator)
2. Datei öffnen: `C:\Windows\System32\drivers\etc\hosts`
3. Dateifilter auf "Alle Dateien" ändern
Füge hinzu:
```
127.0.0.1 public.test.local
127.0.0.1 internal.test.local
```
**2. Docker .env anpassen:**
Bearbeite `docker/dev/frontend/config/.env`:
```bash
API_URL=http://localhost:5001
CLIENT_URL=http://localhost:3000
APP_VERSION=1.1.0
PUBLIC_HOST=public.test.local
INTERNAL_HOST=internal.test.local
```
Bearbeite `docker/dev/docker-compose.yml`:
```yaml
backend-dev:
environment:
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true
- TRUST_PROXY_HOPS=0
frontend-dev:
environment:
- HOST=0.0.0.0
- DANGEROUSLY_DISABLE_HOST_CHECK=true
```
**3. Container starten:**
```bash
./dev.sh
```
**4. Im Browser testen:**
**Public Host** (`http://public.test.local:3000`):
- ✅ Upload-Seite funktioniert
- ✅ UUID Management funktioniert (`/manage/:token`)
- ✅ Social Media Badges angezeigt
- ❌ Kein Admin/Groups/Slideshow-Menü
- ❌ `/moderation` → 404
**Internal Host** (`http://internal.test.local:3000`):
- ✅ Alle Features verfügbar
- ✅ Admin-Bereich, Groups, Slideshow erreichbar
- ✅ Vollständiger API-Zugriff
### API-Tests mit curl
**Public Host - Blockierte Routen (403):**
```bash
curl -H "Host: public.test.local" http://localhost:5001/api/admin/deletion-log
curl -H "Host: public.test.local" http://localhost:5001/api/groups
curl -H "Host: public.test.local" http://localhost:5001/api/auth/login
```
**Public Host - Erlaubte Routen:**
```bash
curl -H "Host: public.test.local" http://localhost:5001/api/upload
curl -H "Host: public.test.local" http://localhost:5001/api/manage/YOUR-UUID
curl -H "Host: public.test.local" http://localhost:5001/api/social-media/platforms
```
**Internal Host - Alle Routen:**
```bash
curl -H "Host: internal.test.local" http://localhost:5001/api/groups
curl -H "Host: internal.test.local" http://localhost:5001/api/admin/deletion-log
```
### Frontend Code-Splitting testen
**Public Host:**
1. Browser DevTools → Network → JS Filter
2. Öffne `http://public.test.local:3000`
3. **Erwartung:** Slideshow/Admin/Groups-Bundles werden **nicht** geladen
4. Navigiere zu `/admin` → Redirect zu 404
**Internal Host:**
1. Öffne `http://internal.test.local:3000`
2. Navigiere zu `/slideshow`
3. **Erwartung:** Lazy-Bundle wird erst jetzt geladen (Code Splitting)
### Rate Limiting testen
Public Host: 20 Uploads/Stunde
```bash
for i in {1..25}; do
echo "Upload $i"
curl -X POST -H "Host: public.test.local" \
http://localhost:5001/api/upload \
-F "file=@test.jpg" -F "group=Test"
done
# Ab Upload 21: HTTP 429 (Too Many Requests)
```
### Troubleshooting
**"Invalid Host header"**
- Lösung: `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development` (Frontend)
**"Alle Routen geben 403"**
- Prüfe `ENABLE_HOST_RESTRICTION=true`
- Prüfe `PUBLIC_HOST` / `INTERNAL_HOST` ENV-Variablen
- Container neu starten
**"public.test.local nicht erreichbar"**
- Prüfe `/etc/hosts`: `cat /etc/hosts | grep test.local`
- DNS-Cache leeren:
- **Linux:** `sudo systemd-resolve --flush-caches`
- **Mac:** `sudo dscacheutil -flushcache`
- **Windows:** `ipconfig /flushdns`
**Feature deaktivieren (Standard Dev):**
```yaml
backend-dev:
environment:
- ENABLE_HOST_RESTRICTION=false
```
### Production Setup
Für Production mit echten Subdomains siehe:
- `FeatureRequests/FEATURE_PLAN-FrontendPublic.md` (Sektion 12: Testing Strategy)
- nginx-proxy-manager Konfiguration erforderlich
- Hosts: `deinprojekt.hobbyhimmel.de` (public), `deinprojekt.lan.hobbyhimmel.de` (internal)
---
## 🚀 Release Management
### Automated Release (EMPFOHLEN)
**Ein Befehl macht alles:**
```bash
npm run release # Patch: 1.2.0 → 1.2.1
npm run release:minor # Minor: 1.2.0 → 1.3.0
npm run release:major # Major: 1.2.0 → 2.0.0
```
**Was passiert automatisch:**
1. ✅ Version in allen package.json erhöht
2. ✅ Footer.js, OpenAPI-Spec, Docker-Images aktualisiert
3. ✅ **CHANGELOG.md automatisch generiert** aus Git-Commits
4. ✅ Git Commit erstellt
5. ✅ Git Tag erstellt
6. ✅ Preview anzeigen + Bestätigung
Dann nur noch:
```bash
git push && git push --tags
```
### Beispiel-Workflow:
```bash
# Features entwickeln mit Conventional Commits:
git commit -m "feat: Add user login"
git commit -m "fix: Fix button alignment"
git commit -m "refactor: Extract ConsentFilter component"
# Release erstellen:
npm run release:minor
# Preview wird angezeigt, dann [Y] drücken
# Push:
git push && git push --tags
```
### CHANGELOG wird automatisch generiert!
Das Release-Script (`scripts/release.sh`) gruppiert deine Commits nach Typ:
- `feat:` → ✨ Features
- `fix:` → 🐛 Fixes
- `refactor:` → ♻️ Refactoring
- `chore:` → 🔧 Chores
- `docs:` → 📚 Documentation
**Wichtig:** Verwende [Conventional Commits](https://www.conventionalcommits.org/)!
### Manuelle Scripts (falls nötig)
```bash
# Version nur synchronisieren (ohne Bump):
./scripts/sync-version.sh
# Version manuell bumpen:
./scripts/bump-version.sh patch # oder minor/major
```
**Version-Synchronisation:**
- Single Source of Truth: `frontend/package.json`
- Wird synchronisiert zu: `backend/package.json`, `Footer.js`, `generate-openapi.js`, Docker Images
---
## Nützliche Befehle
```bash

138
README.md
View File

@ -5,6 +5,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## Features
**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
**Automatic Cleanup**: 🆕 Unapproved groups are automatically deleted after 7 days
**Deletion Log**: 🆕 Complete audit trail of automatically deleted content
@ -20,49 +21,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## 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.
### 🆕 Latest Features (November 2025)
- **🔐 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 Backend Complete - Nov 11):
- Secure UUID-based management tokens for user self-service
- Token-based API for consent revocation and metadata editing
- 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
- Frontend portal coming soon (Tasks 12-18)
- **<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
See the [CHANGELOG](CHANGELOG.md) for a detailed list of improvements and new features.
## Quick Start
@ -108,9 +67,26 @@ docker compose -f docker/dev/docker-compose.yml up -d
- ✅ **Workshop Display**: Required consent to display images on local monitor
- ☐ **Social Media** (optional): Per-platform consent for Facebook, Instagram, TikTok
5. Click "Upload Images" to process the batch
6. Receive your **Group ID** as reference for future contact
6. Receive your **Group ID** and **Management Link** as reference
7. Images are grouped and await moderation approval
### Self-Service Management Portal
After upload, users receive a unique management link (`/manage/:token`) to:
- **View Upload**: See all images and metadata
- **Manage Consents**: Revoke or restore workshop/social media consents
- **Edit Metadata**: Update title, description, year (triggers re-moderation)
- **Manage Images**: Add new images or delete existing ones
- **Delete Group**: Complete removal with double-confirmation
- **Email Contact**: Request deletion of already published social media posts
**Security Features**:
- No authentication required (token-based access)
- Rate limiting: 10 requests per hour per IP
- Brute-force protection: 20 failed attempts → 24h ban
- Complete audit trail of all management actions
### Slideshow Mode
- **Automatic Access**: Navigate to `http://localhost/slideshow`
@ -155,8 +131,13 @@ The application automatically generates optimized preview thumbnails for all upl
### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication)
- **Authentication**: HTTP Basic Auth (username: hobbyadmin, password: set during setup)
- **Access**: `http://localhost/moderation` (requires admin session)
- **Authentication Flow**:
- Built-in login form establishes a server session stored in HttpOnly cookies
- First-time setup wizard creates the initial admin user once `ADMIN_SESSION_SECRET` is configured
- CSRF token must be included (header `X-CSRF-Token`) for any mutating admin API call
- `AUTHENTICATION.md` documents CLI/cURL examples for managing sessions and CSRF tokens
- **Protected Endpoints**: All `/api/admin/*` routes require authentication
- **Features**:
- Review pending image groups before public display
- Visual countdown showing days until automatic deletion (7 days for unapproved groups)
@ -195,31 +176,31 @@ The application automatically generates optimized preview thumbnails for all upl
## 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/
├── .env.backend.example # Backend environment variables documentation
├── .env.frontend.example # Frontend environment variables documentation
├── 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/
│ │ ├── config/.env # Development backend configuration
│ │ └── Dockerfile # Development backend container
│ └── frontend/
│ ├── config/.env # Development frontend configuration
│ ├── config/env.sh # Runtime configuration script
│ ├── config/env.sh # Generates window._env_ from ENV
│ ├── Dockerfile # Development frontend container
│ ├── nginx.conf # Development nginx configuration
│ └── start.sh # Development startup script
└── 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/
│ ├── config/.env # Production backend configuration
│ └── Dockerfile # Production backend container
└── frontend/
├── config/.env # Production frontend configuration
├── config/env.sh # Runtime configuration script
├── config/env.sh # Generates window._env_ from ENV
├── config/htpasswd # HTTP Basic Auth credentials
├── Dockerfile # Production frontend container
└── nginx.conf # Production nginx configuration
@ -227,6 +208,20 @@ docker/
### 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
- **Production**: Uses `docker/prod/` configuration with optimized builds
- **Scripts**: Use `./dev.sh` or `./prod.sh` for easy deployment
@ -524,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)
## Configuration
### 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 |
|----------|---------|-------------|
| `API_URL` | `http://localhost:5000` | Backend API endpoint |
| `CLIENT_URL` | `http://localhost` | Frontend application URL |
| `API_URL` | `http://localhost:5001` | Backend API endpoint (frontend → backend) |
| `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
- **Upload Limits**: 100MB maximum file size for batch uploads

16
TODO.md
View File

@ -66,7 +66,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
[x] In der angezeigten Gruppen soll statt Bilder ansehen Gruppe editieren stehen
[x] Diese bestehende Ansicht (Bilder ansehen) soll als eigene Seite implementiert werden
[x] Ergänzung der Möglichkeit eine Beschreibung zu den Bildern hinzuzufügen
[ ] Erweiterung der ModerationPage um reine Datenbankeditor der sqlite Datenbank.
## 🚀 Deployment-Überlegungen
@ -98,16 +98,16 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
- ✅ Mobile-Kompatibilität
### Nice-to-Have
- 🎨 Drag & Drop Reihenfolge ändern
- 📊 Upload-Progress mit Details
- 🖼️ Thumbnail-Navigation in Slideshow
- 🔄 Batch-Operations (alle entfernen, etc.)
[x] 🎨 Drag & Drop Reihenfolge ändern
[x] 📊 Upload-Progress mit Details
[x] 🖼️ Thumbnail-Navigation in Slideshow
### Future Features
- 👤 User-Management
- 🏷️ Tagging-System
- 📤 Export-Funktionen
- 🎵 Audio-Integration
---

View File

@ -1,15 +0,0 @@
# Backend Environment Variables
# Copy this file to .env and adjust values for local development
# Whether to remove images when starting the server (cleanup)
REMOVE_IMAGES=false
# Node.js environment (development, production, test)
NODE_ENV=development
# Port for the backend server
PORT=5000
# Database settings (if needed in future)
# DB_HOST=localhost
# DB_PORT=3306

3037
backend/docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

@ -1,33 +1,50 @@
{
"name": "backend",
"version": "1.0.0",
"version": "2.0.1",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"server": "nodemon src/index.js",
"server": "nodemon --ignore docs/openapi.json src/index.js",
"client": "npm run dev --prefix ../frontend",
"client-build": "cd ../frontend && npm run build && serve -s build -l 80",
"dev": "concurrently \"npm run server\" \"npm run client\"",
"build": "concurrently \"npm run server\" \"npm run client-build\""
"build": "concurrently \"npm run server\" \"npm run client-build\"",
"generate-openapi": "node src/generate-openapi.js",
"test-openapi": "node test-openapi-paths.js",
"validate-openapi": "redocly lint docs/openapi.json",
"test": "jest --coverage",
"test:watch": "jest --watch",
"test:api": "jest tests/api",
"create-admin": "node src/scripts/createAdminUser.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"connect-sqlite3": "^0.9.16",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-session": "^1.18.2",
"find-remove": "^2.0.3",
"fs": "^0.0.1-security",
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"sharp": "^0.34.4",
"shortid": "^2.2.16",
"sqlite3": "^5.1.7",
"uuid": "^13.0.0"
},
"devDependencies": {
"@redocly/cli": "^2.11.1",
"@stoplight/prism-cli": "^5.14.2",
"concurrently": "^6.0.0",
"nodemon": "^2.0.7"
"jest": "^30.2.0",
"nodemon": "^2.0.7",
"supertest": "^7.1.4",
"swagger-autogen": "^2.23.7",
"swagger-ui-express": "^5.0.1"
}
}

View File

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

View File

@ -5,27 +5,41 @@ const fs = require('fs');
class DatabaseManager {
constructor() {
this.db = null;
// Place database file under data/db
this.dbPath = path.join(__dirname, '../data/db/image_uploader.db');
this.dbPath = null;
this.schemaPath = path.join(__dirname, 'schema.sql');
}
getDatabasePath() {
if (process.env.NODE_ENV === 'test') {
return ':memory:';
}
return path.join(__dirname, '../data/db/image_uploader.db');
}
async initialize() {
try {
// Stelle sicher, dass das data-Verzeichnis existiert
const dataDir = path.dirname(this.dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
if (!this.dbPath) {
this.dbPath = this.getDatabasePath();
}
// Stelle sicher, dass das data-Verzeichnis existiert (skip for in-memory)
if (this.dbPath !== ':memory:') {
const dataDir = path.dirname(this.dbPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
}
// Öffne Datenbankverbindung
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('Fehler beim Öffnen der Datenbank:', err.message);
throw err;
} else {
console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
}
// Öffne Datenbankverbindung (promisify for async/await)
await new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('Fehler beim Öffnen der Datenbank:', err.message);
reject(err);
} else {
console.log('✓ SQLite Datenbank verbunden:', this.dbPath);
resolve();
}
});
});
// Aktiviere Foreign Keys
@ -37,8 +51,12 @@ class DatabaseManager {
// Run database migrations (automatic on startup)
await this.runMigrations();
// Generate missing previews for existing images
await this.generateMissingPreviews();
const skipPreviewGeneration = ['1', 'true', 'yes'].includes(String(process.env.SKIP_PREVIEW_GENERATION || '').toLowerCase());
// Generate missing previews for existing images (skip in test mode or when explicitly disabled)
if (process.env.NODE_ENV !== 'test' && !skipPreviewGeneration) {
await this.generateMissingPreviews();
}
console.log('✓ Datenbank erfolgreich initialisiert');
} catch (error) {
@ -155,6 +173,31 @@ class DatabaseManager {
END
`);
console.log('✓ Trigger erstellt');
// Admin Users Tabelle (für Session-Authentication)
await this.run(`
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_active BOOLEAN NOT NULL DEFAULT 1,
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username)');
await this.run(`
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
AFTER UPDATE ON admin_users
FOR EACH ROW
BEGIN
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
console.log('✓ Admin Users Tabelle erstellt');
console.log('✅ Datenbank-Schema vollständig erstellt');
} catch (error) {
@ -176,6 +219,19 @@ class DatabaseManager {
});
}
// Execute multi-statement SQL scripts (z. B. Migrationen mit Triggern)
exec(sql) {
return new Promise((resolve, reject) => {
this.db.exec(sql, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// Promise-wrapper für sqlite3.get
get(sql, params = []) {
return new Promise((resolve, reject) => {
@ -361,28 +417,26 @@ class DatabaseManager {
// Execute migration in a transaction
await this.run('BEGIN TRANSACTION');
// Remove comments (both line and inline) before splitting
// Remove comments (both line and inline) to avoid sqlite parser issues
const cleanedSql = sql
.split('\n')
.map(line => {
// Remove inline comments (everything after --)
const commentIndex = line.indexOf('--');
if (commentIndex !== -1) {
return line.substring(0, commentIndex);
}
return line;
})
.join('\n');
// Split by semicolon and execute each statement
const statements = cleanedSql
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
for (const statement of statements) {
await this.run(statement);
.join('\n')
.trim();
if (!cleanedSql) {
console.warn(` ⚠️ Migration ${file} enthält keinen ausführbaren SQL-Code, übersprungen`);
await this.run('COMMIT');
continue;
}
await this.exec(cleanedSql);
// Record migration
await this.run(

View File

@ -0,0 +1,21 @@
-- Migration: Create admin_users table for server-side admin authentication
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_active BOOLEAN NOT NULL DEFAULT 1,
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
AFTER UPDATE ON admin_users
FOR EACH ROW
BEGIN
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

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

@ -47,4 +47,26 @@ AFTER UPDATE ON groups
FOR EACH ROW
BEGIN
UPDATE groups SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Admin Users Tabelle zur Verwaltung von Backend-Admins
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
is_active BOOLEAN NOT NULL DEFAULT 1,
requires_password_change BOOLEAN NOT NULL DEFAULT 0,
last_login_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_users_username ON admin_users(username);
CREATE TRIGGER IF NOT EXISTS update_admin_users_timestamp
AFTER UPDATE ON admin_users
FOR EACH ROW
BEGIN
UPDATE admin_users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

View File

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

View File

@ -14,6 +14,8 @@ const auditLogMiddleware = (req, res, next) => {
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
const userAgent = req.get('user-agent') || 'unknown';
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
@ -33,7 +35,9 @@ const auditLogMiddleware = (req, res, next) => {
errorMessage,
ipAddress,
userAgent,
requestData
requestData,
sourceHost,
sourceType
});
} catch (error) {
console.error('Failed to write audit log:', error);

View File

@ -0,0 +1,20 @@
/**
* Admin Authentication Middleware
* Validates server-side session for admin users
*/
const requireAdminAuth = (req, res, next) => {
const sessionUser = req.session && req.session.user;
if (!sessionUser || sessionUser.role !== 'admin') {
return res.status(403).json({
error: 'Zugriff verweigert',
reason: 'SESSION_REQUIRED'
});
}
res.locals.adminUser = sessionUser;
next();
};
module.exports = { requireAdminAuth };

View File

@ -0,0 +1,40 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const requireCsrf = (req, res, next) => {
if (SAFE_METHODS.has(req.method.toUpperCase())) {
return next();
}
if (!req.session || !req.session.user) {
return res.status(403).json({
error: 'Zugriff verweigert',
reason: 'SESSION_REQUIRED'
});
}
if (!req.session.csrfToken) {
return res.status(403).json({
error: 'CSRF erforderlich',
reason: 'CSRF_SESSION_MISSING'
});
}
const headerToken = req.headers['x-csrf-token'];
if (!headerToken) {
return res.status(403).json({
error: 'CSRF erforderlich',
reason: 'CSRF_HEADER_MISSING'
});
}
if (headerToken !== req.session.csrfToken) {
return res.status(403).json({
error: 'CSRF ungültig',
reason: 'CSRF_TOKEN_INVALID'
});
}
next();
};
module.exports = { requireCsrf };

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

@ -1,12 +1,17 @@
const express = require("express");
const fileUpload = require("express-fileupload");
const cors = require("./cors");
const session = require("./session");
const hostGate = require("./hostGate");
const applyMiddlewares = (app) => {
app.use(fileUpload());
app.use(cors);
app.use(session);
// JSON Parser für PATCH/POST Requests
app.use(express.json());
// Host Gate: Blockiert geschützte Routen für public Host
app.use(hostGate);
};
module.exports = { applyMiddlewares };

View File

@ -13,12 +13,21 @@ const blockedIPs = new Map(); // IP -> { reason, blockedUntil, failedAttempts
// Konfiguration
const RATE_LIMIT = {
MAX_REQUESTS_PER_HOUR: 10,
MAX_REQUESTS_PER_HOUR: process.env.NODE_ENV === 'production' ? 10 : 100, // 100 für Dev, 10 für Production
WINDOW_MS: 60 * 60 * 1000, // 1 Stunde
BRUTE_FORCE_THRESHOLD: 20,
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
*/
@ -169,13 +178,63 @@ function getStatistics() {
reason: info.reason,
blockedUntil: new Date(info.blockedUntil).toISOString(),
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 = {
rateLimitMiddleware,
recordFailedTokenValidation,
cleanupExpiredEntries,
getStatistics
getStatistics,
publicUploadLimiter
};

View File

@ -0,0 +1,71 @@
const fs = require('fs');
const path = require('path');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const SESSION_FILENAME = process.env.ADMIN_SESSION_DB || 'sessions.sqlite';
const SESSION_DIR = process.env.ADMIN_SESSION_DIR
? path.resolve(process.env.ADMIN_SESSION_DIR)
: path.join(__dirname, '..', 'data');
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
const ADMIN_SESSION_COOKIE_SECURE = process.env.ADMIN_SESSION_COOKIE_SECURE;
const parseBooleanEnv = (value) => {
if (typeof value !== 'string') {
return undefined;
}
switch (value.toLowerCase().trim()) {
case 'true':
case '1':
case 'yes':
case 'on':
return true;
case 'false':
case '0':
case 'no':
case 'off':
return false;
default:
return undefined;
}
};
const secureOverride = parseBooleanEnv(ADMIN_SESSION_COOKIE_SECURE);
const cookieSecure = secureOverride ?? IS_PRODUCTION;
if (IS_PRODUCTION && secureOverride === false) {
console.warn('[Session] ADMIN_SESSION_COOKIE_SECURE=false detected secure cookies disabled in production. Only do this on trusted HTTP deployments.');
}
if (!SESSION_SECRET) {
throw new Error('ADMIN_SESSION_SECRET is required for session management');
}
// Ensure session directory exists so SQLite can create the DB file
if (!fs.existsSync(SESSION_DIR)) {
fs.mkdirSync(SESSION_DIR, { recursive: true });
}
const store = new SQLiteStore({
db: SESSION_FILENAME,
dir: SESSION_DIR,
ttl: 8 * 60 * 60 // seconds
});
const sessionMiddleware = session({
name: 'sid',
store,
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: cookieSecure,
sameSite: 'strict',
maxAge: 8 * 60 * 60 * 1000 // 8 hours
}
});
module.exports = sessionMiddleware;

View File

@ -0,0 +1,67 @@
const dbManager = require('../database/DatabaseManager');
class AdminUserRepository {
async countActiveAdmins() {
const row = await dbManager.get(
'SELECT COUNT(*) as count FROM admin_users WHERE is_active = 1'
);
return row ? row.count : 0;
}
async getByUsername(username) {
return dbManager.get(
'SELECT * FROM admin_users WHERE username = ?',
[username]
);
}
async getById(id) {
return dbManager.get(
'SELECT * FROM admin_users WHERE id = ?',
[id]
);
}
async listActiveAdmins() {
return dbManager.all(
`SELECT id, username, role, is_active, requires_password_change, last_login_at, created_at, updated_at
FROM admin_users
WHERE is_active = 1
ORDER BY username ASC`
);
}
async createAdminUser({ username, passwordHash, role = 'admin', requiresPasswordChange = false }) {
const result = await dbManager.run(
`INSERT INTO admin_users (username, password_hash, role, requires_password_change)
VALUES (?, ?, ?, ?)` ,
[username, passwordHash, role, requiresPasswordChange ? 1 : 0]
);
return result.id;
}
async updatePassword(id, newPasswordHash, requiresPasswordChange = false) {
await dbManager.run(
`UPDATE admin_users
SET password_hash = ?, requires_password_change = ?
WHERE id = ?`,
[newPasswordHash, requiresPasswordChange ? 1 : 0, id]
);
}
async markInactive(id) {
await dbManager.run(
'UPDATE admin_users SET is_active = 0 WHERE id = ?',
[id]
);
}
async recordSuccessfulLogin(id) {
await dbManager.run(
'UPDATE admin_users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?',
[id]
);
}
}
module.exports = new AdminUserRepository();

View File

@ -20,6 +20,8 @@ class ManagementAuditLogRepository {
* @param {string} logData.ipAddress - IP-Adresse
* @param {string} logData.userAgent - User-Agent
* @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
*/
async logAction(logData) {
@ -34,22 +36,50 @@ class ManagementAuditLogRepository {
managementToken: undefined // Token nie loggen
} : null;
const query = `
INSERT INTO management_audit_log
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
// Prüfe ob Spalten source_host und source_type existieren
const tableInfo = await dbManager.all(`PRAGMA table_info(management_audit_log)`);
const hasSourceColumns = tableInfo.some(col => col.name === 'source_host');
let query, params;
const result = await dbManager.run(query, [
logData.groupId || null,
maskedToken,
logData.action,
logData.success ? 1 : 0,
logData.errorMessage || null,
logData.ipAddress || null,
logData.userAgent || null,
sanitizedData ? JSON.stringify(sanitizedData) : null
]);
if (hasSourceColumns) {
query = `
INSERT INTO management_audit_log
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data, source_host, source_type)
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,
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;
}

View File

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

File diff suppressed because it is too large Load Diff

195
backend/src/routes/auth.js Normal file
View File

@ -0,0 +1,195 @@
const express = require('express');
const router = express.Router();
const AdminAuthService = require('../services/AdminAuthService');
const { requireAdminAuth } = require('../middlewares/auth');
const { requireCsrf } = require('../middlewares/csrf');
router.get('/setup/status', async (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Check onboarding status'
#swagger.description = 'Returns whether the initial admin setup is still pending and if a session already exists.'
*/
try {
const needsSetup = await AdminAuthService.needsInitialSetup();
const sessionUser = req.session && req.session.user
? {
id: req.session.user.id,
username: req.session.user.username,
role: req.session.user.role,
requiresPasswordChange: Boolean(req.session.user.requiresPasswordChange)
}
: null;
res.json({
needsSetup,
hasSession: Boolean(sessionUser),
user: sessionUser
});
} catch (error) {
console.error('[Auth] setup/status error:', error);
res.status(500).json({ error: 'SETUP_STATUS_FAILED' });
}
});
router.post('/setup/initial-admin', async (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Complete initial admin setup'
#swagger.description = 'Creates the very first admin account and immediately starts a session.'
*/
try {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'USERNAME_AND_PASSWORD_REQUIRED' });
}
const user = await AdminAuthService.createInitialAdmin({ username, password });
const csrfToken = AdminAuthService.startSession(req, {
...user,
requiresPasswordChange: false
});
res.status(201).json({
success: true,
user: {
id: user.id,
username: user.username,
role: user.role
},
csrfToken
});
} catch (error) {
console.error('[Auth] initial setup error:', error.message);
switch (error.message) {
case 'SETUP_ALREADY_COMPLETED':
return res.status(409).json({ error: 'SETUP_ALREADY_COMPLETED' });
case 'USERNAME_REQUIRED':
return res.status(400).json({ error: 'USERNAME_REQUIRED' });
case 'PASSWORD_TOO_WEAK':
return res.status(400).json({ error: 'PASSWORD_TOO_WEAK' });
default:
if (error.message && error.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'USERNAME_IN_USE' });
}
return res.status(500).json({ error: 'INITIAL_SETUP_FAILED' });
}
}
});
router.post('/login', async (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Admin login'
#swagger.description = 'Starts a server-side admin session and returns a CSRF token.'
*/
try {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'USERNAME_AND_PASSWORD_REQUIRED' });
}
if (await AdminAuthService.needsInitialSetup()) {
return res.status(409).json({ error: 'SETUP_REQUIRED' });
}
const user = await AdminAuthService.verifyCredentials(username, password);
if (!user) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
}
const csrfToken = AdminAuthService.startSession(req, user);
res.json({
success: true,
user: {
id: user.id,
username: user.username,
role: user.role,
requiresPasswordChange: user.requiresPasswordChange
},
csrfToken
});
} catch (error) {
console.error('[Auth] login error:', error);
res.status(500).json({ error: 'LOGIN_FAILED' });
}
});
router.post('/logout', async (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Terminate admin session'
#swagger.description = 'Destroys the current session and clears the sid cookie.'
*/
try {
await AdminAuthService.destroySession(req);
res.clearCookie('sid');
res.status(204).send();
} catch (error) {
console.error('[Auth] logout error:', error);
res.status(500).json({ error: 'LOGOUT_FAILED' });
}
});
router.get('/csrf-token', requireAdminAuth, (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Fetch CSRF token'
#swagger.description = 'Returns a CSRF token for the active admin session (session required).'
*/
if (!req.session.csrfToken) {
req.session.csrfToken = AdminAuthService.generateCsrfToken();
}
res.json({ csrfToken: req.session.csrfToken });
});
router.post('/change-password', requireAdminAuth, requireCsrf, async (req, res) => {
/*
#swagger.tags = ['Admin Authentication']
#swagger.summary = 'Change admin password'
#swagger.description = 'Allows a logged-in admin to rotate their password (CSRF protected).'
*/
try {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'CURRENT_AND_NEW_PASSWORD_REQUIRED' });
}
const user = await AdminAuthService.changePassword({
userId: req.session.user.id,
currentPassword,
newPassword
});
req.session.user = {
...req.session.user,
requiresPasswordChange: false
};
res.json({
success: true,
user: {
id: user.id,
username: user.username,
role: user.role,
requiresPasswordChange: false
}
});
} catch (error) {
console.error('[Auth] change password error:', error.message || error);
switch (error.message) {
case 'CURRENT_PASSWORD_REQUIRED':
return res.status(400).json({ error: 'CURRENT_PASSWORD_REQUIRED' });
case 'PASSWORD_TOO_WEAK':
return res.status(400).json({ error: 'PASSWORD_TOO_WEAK' });
case 'INVALID_CURRENT_PASSWORD':
return res.status(400).json({ error: 'INVALID_CURRENT_PASSWORD' });
case 'USER_NOT_FOUND':
return res.status(404).json({ error: 'USER_NOT_FOUND' });
default:
return res.status(500).json({ error: 'PASSWORD_CHANGE_FAILED' });
}
}
});
module.exports = router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,10 @@ const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager');
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
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
router.use(rateLimitMiddleware);
@ -25,6 +29,35 @@ const validateToken = (token) => {
* @throws {500} Server error
*/
router.get('/:token', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Validate token and load group data'
#swagger.description = 'Validates management token and returns complete group data with images and consents'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.responses[200] = {
description: 'Group data loaded successfully',
schema: {
success: true,
data: {
groupId: 'abc123',
groupName: 'Familie_Mueller',
managementToken: '550e8400-e29b-41d4-a716-446655440000',
images: [],
socialMediaConsents: [],
display_in_workshop: true
}
}
}
#swagger.responses[404] = {
description: 'Invalid token or group deleted'
}
*/
try {
const { token } = req.params;
@ -85,6 +118,44 @@ router.get('/:token', async (req, res) => {
* @throws {500} Server error
*/
router.put('/:token/consents', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Revoke or restore consents'
#swagger.description = 'Updates workshop or social media consents for a group'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
consentType: 'workshop',
action: 'revoke',
platformId: 1
}
}
#swagger.responses[200] = {
description: 'Consent updated successfully',
schema: {
success: true,
message: 'Workshop consent revoked successfully',
data: {
consentType: 'workshop',
newValue: false
}
}
}
#swagger.responses[400] = {
description: 'Invalid request parameters'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { consentType, action, platformId } = req.body;
@ -144,6 +215,20 @@ router.put('/:token/consents', async (req, res) => {
[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({
success: true,
message: `Workshop consent ${action}d successfully`,
@ -160,9 +245,60 @@ router.put('/:token/consents', async (req, res) => {
const socialMediaRepo = new SocialMediaRepository(dbManager);
if (action === 'revoke') {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
// Check if consent exists before revoking
const existing = await dbManager.get(
'SELECT id FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
await socialMediaRepo.revokeConsent(groupData.groupId, platformId);
} else {
// Can't revoke what doesn't exist - return error
return res.status(400).json({
success: false,
error: 'Cannot revoke consent that was never granted'
});
}
} else {
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
// action === 'restore'
// Check if consent exists
const existing = await dbManager.get(
'SELECT id, revoked FROM group_social_media_consents WHERE group_id = ? AND platform_id = ?',
[groupData.groupId, platformId]
);
if (existing) {
// Restore existing consent
await socialMediaRepo.restoreConsent(groupData.groupId, platformId);
} else {
// Create new consent (user wants to grant consent for a platform they didn't select during upload)
await dbManager.run(
`INSERT INTO group_social_media_consents (group_id, platform_id, consented, consent_timestamp)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)`,
[groupData.groupId, platformId]
);
}
}
// 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({
@ -185,6 +321,138 @@ router.put('/:token/consents', async (req, res) => {
}
});
/**
* PUT /api/manage/:token/images/descriptions
* Batch update image descriptions for a group
*
* Body:
* - descriptions: [{ imageId: number, description: string }, ...]
*
* @returns {Object} Update result with count of updated images
* @throws {400} Invalid request or validation error
* @throws {404} Token invalid or not found
* @throws {500} Server error
*/
router.put('/:token/images/descriptions', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Batch update image descriptions'
#swagger.description = 'Updates descriptions for multiple images in a group (max 200 chars each)'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
descriptions: [
{ imageId: 1, description: 'Sonnenuntergang' },
{ imageId: 2, description: 'Gruppenfoto' }
]
}
}
#swagger.responses[200] = {
description: 'Descriptions updated',
schema: {
success: true,
message: '2 image descriptions updated successfully',
updatedCount: 2
}
}
#swagger.responses[400] = {
description: 'Invalid request or description too long'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { descriptions } = req.body;
// Validate token format
if (!validateToken(token)) {
return res.status(404).json({
success: false,
error: 'Invalid management token format'
});
}
// Validate descriptions array
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return res.status(400).json({
success: false,
error: 'descriptions must be a non-empty array'
});
}
// Validate each description
for (const desc of descriptions) {
if (!desc.imageId || typeof desc.imageId !== 'number') {
return res.status(400).json({
success: false,
error: 'Each description must contain a valid imageId'
});
}
if (desc.description && desc.description.length > 200) {
return res.status(400).json({
success: false,
error: `Description for image ${desc.imageId} exceeds 200 characters`
});
}
}
// Load group by management token
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Update descriptions
let updatedCount = 0;
for (const desc of descriptions) {
const updated = await groupRepository.updateImageDescription(
desc.imageId,
groupData.groupId,
desc.description || null
);
if (updated) {
updatedCount++;
}
}
await res.auditLog('update_image_descriptions', true, groupData.groupId,
`Updated ${updatedCount} image descriptions`);
res.json({
success: true,
message: `${updatedCount} image description(s) updated successfully`,
data: {
groupId: groupData.groupId,
updatedImages: updatedCount,
totalRequested: descriptions.length
}
});
} catch (error) {
console.error('Error updating image descriptions:', error);
await res.auditLog('update_image_descriptions', false, null, error.message);
res.status(500).json({
success: false,
error: 'Failed to update image descriptions'
});
}
});
/**
* PUT /api/manage/:token/metadata
* Update group metadata (title, description, name)
@ -201,6 +469,45 @@ router.put('/:token/consents', async (req, res) => {
* @throws {500} Server error
*/
router.put('/:token/metadata', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Update group metadata'
#swagger.description = 'Updates group title, description or name. Sets approved=0 (returns to moderation).'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['body'] = {
in: 'body',
required: true,
schema: {
title: 'Sommercamp 2025',
description: 'Tolle Veranstaltung',
name: 'Familie_Mueller'
}
}
#swagger.responses[200] = {
description: 'Metadata updated',
schema: {
success: true,
message: 'Metadata updated successfully',
data: {
groupId: 'abc123',
updatedFields: ['title', 'description'],
requiresModeration: true
}
}
}
#swagger.responses[400] = {
description: 'No fields provided'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
const { title, description, name } = req.body;
@ -298,6 +605,43 @@ router.put('/:token/metadata', async (req, res) => {
* @throws {500} Server error
*/
router.post('/:token/images', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Add new images to group'
#swagger.description = 'Uploads additional images to existing group. Sets approved=0 (requires re-moderation). Max 50 images per group.'
#swagger.consumes = ['multipart/form-data']
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['images'] = {
in: 'formData',
type: 'file',
required: true,
description: 'Image files to upload (JPEG, PNG)'
}
#swagger.responses[200] = {
description: 'Images uploaded',
schema: {
success: true,
message: '3 images added successfully',
data: {
groupId: 'abc123',
newImagesCount: 3,
totalImagesCount: 15
}
}
}
#swagger.responses[400] = {
description: 'No images or limit exceeded (max 50)'
}
#swagger.responses[404] = {
description: 'Invalid token'
}
*/
try {
const { token } = req.params;
@ -454,6 +798,43 @@ router.post('/:token/images', async (req, res) => {
* @throws {500} Server error
*/
router.delete('/:token/images/:imageId', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Delete single image'
#swagger.description = 'Deletes a specific image from group (files + DB entry). Sets approved=0. Cannot delete last image.'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.parameters['imageId'] = {
in: 'path',
required: true,
type: 'integer',
description: 'Image ID',
example: 42
}
#swagger.responses[200] = {
description: 'Image deleted',
schema: {
success: true,
message: 'Image deleted successfully',
data: {
groupId: 'abc123',
imageId: 42,
remainingImages: 11
}
}
}
#swagger.responses[400] = {
description: 'Cannot delete last image'
}
#swagger.responses[404] = {
description: 'Invalid token or image not found'
}
*/
try {
const { token, imageId } = req.params;
@ -567,6 +948,33 @@ router.delete('/:token/images/:imageId', async (req, res) => {
* @throws {500} Server error
*/
router.delete('/:token', async (req, res) => {
/*
#swagger.tags = ['Management Portal']
#swagger.summary = 'Delete complete group'
#swagger.description = 'Deletes entire group with all images, consents and metadata. Creates deletion_log entry. Removes all files (originals + previews).'
#swagger.parameters['token'] = {
in: 'path',
required: true,
type: 'string',
description: 'Management token (UUID v4)',
example: '550e8400-e29b-41d4-a716-446655440000'
}
#swagger.responses[200] = {
description: 'Group deleted',
schema: {
success: true,
message: 'Group and all associated data deleted successfully',
data: {
groupId: 'abc123',
imagesDeleted: 12,
deletionTimestamp: '2025-11-15T16:30:00Z'
}
}
}
#swagger.responses[404] = {
description: 'Invalid token or group already deleted'
}
*/
try {
const { token } = req.params;
@ -637,6 +1045,18 @@ router.delete('/:token', async (req, res) => {
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({
success: true,
message: 'Group and all associated data deleted successfully',
@ -656,4 +1076,116 @@ router.delete('/:token', 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 {
const { token } = req.params;
const { imageIds } = req.body;
// Validate token format
if (!validateToken(token)) {
recordFailedTokenValidation(req);
return res.status(400).json({
success: false,
error: 'Invalid management token format'
});
}
// Validate imageIds
if (!imageIds || !Array.isArray(imageIds) || imageIds.length === 0) {
return res.status(400).json({
success: false,
error: 'imageIds array is required and cannot be empty'
});
}
// Validate that all imageIds are numbers
const invalidIds = imageIds.filter(id => !Number.isInteger(id) || id <= 0);
if (invalidIds.length > 0) {
return res.status(400).json({
success: false,
error: `Invalid image IDs: ${invalidIds.join(', ')}. Image IDs must be positive integers`
});
}
// Load group by token to get groupId
const groupData = await groupRepository.getGroupByManagementToken(token);
if (!groupData) {
recordFailedTokenValidation(req);
await res.auditLog('reorder_images', false, null, 'Token not found or group deleted');
return res.status(404).json({
success: false,
error: 'Management token not found or group has been deleted'
});
}
// Execute reorder using GroupRepository
const result = await groupRepository.updateImageOrder(groupData.groupId, imageIds);
await res.auditLog('reorder_images', true, groupData.groupId, `Reordered ${result.updatedImages} images`);
res.status(200).json({
success: true,
message: 'Image order updated successfully',
data: result
});
} catch (error) {
console.error(`[MANAGEMENT] Error reordering images for token ${req.params.token}:`, error.message);
await res.auditLog('reorder_images', false, null, error.message);
// Handle specific errors
if (error.message.includes('not found')) {
return res.status(404).json({
success: false,
error: error.message
});
}
if (error.message.includes('Invalid image IDs') ||
error.message.includes('Missing image IDs')) {
return res.status(400).json({
success: false,
error: error.message
});
}
res.status(500).json({
success: false,
error: 'Failed to reorder images'
});
}
});
module.exports = router;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
const express = require('express');
const SocialMediaRepository = require('../repositories/SocialMediaRepository');
const dbManager = require('../database/DatabaseManager');
const router = express.Router();
/**
* Public endpoint: list active social media platforms for consent selection
*/
router.get('/social-media/platforms', async (req, res) => {
/*
#swagger.tags = ['Consent Management']
#swagger.summary = 'List active social media platforms'
#swagger.description = 'Public endpoint that exposes the available platforms for consent selection on the upload form.'
*/
try {
const socialMediaRepo = new SocialMediaRepository(dbManager);
const platforms = await socialMediaRepo.getActivePlatforms();
res.json(platforms);
} catch (error) {
console.error('[SOCIAL_MEDIA] Failed to fetch platforms:', error);
res.status(500).json({
error: 'Failed to fetch social media platforms',
message: error.message
});
}
});
module.exports = router;

View File

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

View File

@ -0,0 +1,102 @@
#!/usr/bin/env node
const bcrypt = require('bcryptjs');
const dbManager = require('../database/DatabaseManager');
const AdminUserRepository = require('../repositories/AdminUserRepository');
const DEFAULT_SALT_ROUNDS = parseInt(process.env.ADMIN_PASSWORD_SALT_ROUNDS || '12', 10);
const printUsage = () => {
console.log('Usage: node src/scripts/createAdminUser.js --username <name> --password <pass> [--role <role>] [--require-password-change]');
console.log('Example: npm run create-admin -- --username admin2 --password "SehrSicher123!"');
};
const parseArgs = () => {
const rawArgs = process.argv.slice(2);
const args = {};
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if (!arg.startsWith('--')) {
continue;
}
const key = arg.slice(2);
const next = rawArgs[i + 1];
if (!next || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i++;
}
}
return args;
};
const validateInput = ({ username, password }) => {
if (!username || !username.trim()) {
throw new Error('USERNAME_REQUIRED');
}
if (!password || password.length < 10) {
throw new Error('PASSWORD_TOO_WEAK');
}
};
(async () => {
const args = parseArgs();
if (args.help || args.h) {
printUsage();
process.exit(0);
}
try {
validateInput(args);
} catch (validationError) {
console.error('⚠️ Validation error:', validationError.message);
printUsage();
process.exit(1);
}
const normalizedUsername = args.username.trim().toLowerCase();
const role = args.role || 'admin';
const requirePasswordChange = Boolean(args['require-password-change']);
// Skip expensive preview generation for CLI usage
process.env.SKIP_PREVIEW_GENERATION = process.env.SKIP_PREVIEW_GENERATION || '1';
try {
await dbManager.initialize();
const existingUser = await AdminUserRepository.getByUsername(normalizedUsername);
if (existingUser) {
console.error(`❌ Benutzer '${normalizedUsername}' existiert bereits.`);
process.exit(1);
}
const passwordHash = await bcrypt.hash(args.password, DEFAULT_SALT_ROUNDS);
const id = await AdminUserRepository.createAdminUser({
username: normalizedUsername,
passwordHash,
role,
requiresPasswordChange: requirePasswordChange
});
console.log('✅ Admin-Benutzer angelegt:');
console.log(` ID: ${id}`);
console.log(` Username: ${normalizedUsername}`);
console.log(` Rolle: ${role}`);
console.log(` Passwort-Änderung erforderlich: ${requirePasswordChange}`);
} catch (error) {
console.error('❌ Fehler beim Anlegen des Admin-Benutzers:', error.message);
process.exit(1);
} finally {
try {
await dbManager.close();
} catch (closeError) {
console.warn('⚠️ Datenbank konnte nicht sauber geschlossen werden:', closeError.message);
}
}
})();

View File

@ -1,7 +1,21 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const initiateResources = require('./utils/initiate-resources');
const dbManager = require('./database/DatabaseManager');
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
let swaggerUi = null;
try {
swaggerUi = require('swagger-ui-express');
} catch (e) {
swaggerUi = null;
}
class Server {
_port;
@ -10,10 +24,42 @@ class Server {
constructor(port) {
this._port = port;
this._app = express();
const trustProxyHops = Number.parseInt(process.env.TRUST_PROXY_HOPS ?? '1', 10);
if (!Number.isNaN(trustProxyHops) && trustProxyHops > 0) {
this._app.set('trust proxy', trustProxyHops);
}
}
async generateOpenApiSpecIfNeeded() {
if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') {
return;
}
try {
const generateOpenApi = require('./generate-openapi');
console.log('🔄 Generating OpenAPI specification...');
await generateOpenApi();
console.log('✓ OpenAPI spec generated');
} catch (error) {
console.warn('⚠️ Failed to generate OpenAPI spec:', error.message);
}
}
loadSwaggerDocument() {
try {
const specPath = path.join(__dirname, '..', 'docs', 'openapi.json');
const raw = fs.readFileSync(specPath, 'utf8');
return JSON.parse(raw);
} catch (error) {
console.warn('⚠️ Unable to load Swagger document:', error.message);
return null;
}
}
async start() {
try {
await this.generateOpenApiSpecIfNeeded();
// Initialisiere Datenbank
console.log('🔄 Initialisiere Datenbank...');
await dbManager.initialize();
@ -22,18 +68,60 @@ class Server {
// Starte Express Server
initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload'));
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
// Mount Swagger UI in dev only when available
if (process.env.NODE_ENV !== 'production' && swaggerUi) {
const swaggerDocument = this.loadSwaggerDocument();
if (swaggerDocument) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
console.log(' Swagger UI mounted at /api/docs (dev only)');
}
}
this._app.listen(this._port, () => {
console.log(`✅ Server läuft auf Port ${this._port}`);
console.log(`📊 SQLite Datenbank aktiv`);
// Speichere SchedulerService in app für Admin-Endpoints
this._app.set('schedulerService', SchedulerService);
// Starte Scheduler für automatisches Cleanup
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) {
console.error('💥 Fehler beim Serverstart:', error);
process.exit(1);
}
}
// Expose app for testing
getApp() {
return this._app;
}
// Initialize app without listening (for tests)
async initializeApp() {
await dbManager.initialize();
initiateResources(this._app);
this._app.use('/upload', express.static( __dirname + '/upload'));
this._app.use('/api/previews', express.static( __dirname + '/data/previews'));
if (process.env.NODE_ENV !== 'production' && swaggerUi) {
const swaggerDocument = this.loadSwaggerDocument();
if (swaggerDocument) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}
}
return this._app;
}
}
module.exports = Server;

View File

@ -0,0 +1,164 @@
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const AdminUserRepository = require('../repositories/AdminUserRepository');
const DEFAULT_SALT_ROUNDS = parseInt(process.env.ADMIN_PASSWORD_SALT_ROUNDS || '12', 10);
class AdminAuthService {
async needsInitialSetup() {
const count = await AdminUserRepository.countActiveAdmins();
return count === 0;
}
async createInitialAdmin({ username, password }) {
const trimmedUsername = (username || '').trim().toLowerCase();
if (!trimmedUsername) {
throw new Error('USERNAME_REQUIRED');
}
if (!password || password.length < 10) {
throw new Error('PASSWORD_TOO_WEAK');
}
const needsSetup = await this.needsInitialSetup();
if (!needsSetup) {
throw new Error('SETUP_ALREADY_COMPLETED');
}
const passwordHash = await this.hashPassword(password);
const id = await AdminUserRepository.createAdminUser({
username: trimmedUsername,
passwordHash,
role: 'admin',
requiresPasswordChange: false
});
return {
id,
username: trimmedUsername,
role: 'admin'
};
}
async createAdminUser({ username, password, role = 'admin', requiresPasswordChange = false }) {
const trimmedUsername = (username || '').trim().toLowerCase();
if (!trimmedUsername) {
throw new Error('USERNAME_REQUIRED');
}
if (!password || password.length < 10) {
throw new Error('PASSWORD_TOO_WEAK');
}
const normalizedRole = (role || 'admin').trim().toLowerCase();
const targetRole = normalizedRole || 'admin';
const existing = await AdminUserRepository.getByUsername(trimmedUsername);
if (existing) {
throw new Error('USERNAME_IN_USE');
}
const passwordHash = await this.hashPassword(password);
const id = await AdminUserRepository.createAdminUser({
username: trimmedUsername,
passwordHash,
role: targetRole,
requiresPasswordChange
});
return {
id,
username: trimmedUsername,
role: targetRole,
requiresPasswordChange: Boolean(requiresPasswordChange)
};
}
async changePassword({ userId, currentPassword, newPassword }) {
if (!userId) {
throw new Error('USER_NOT_FOUND');
}
if (!currentPassword) {
throw new Error('CURRENT_PASSWORD_REQUIRED');
}
if (!newPassword || newPassword.length < 10) {
throw new Error('PASSWORD_TOO_WEAK');
}
const userRecord = await AdminUserRepository.getById(userId);
if (!userRecord || !userRecord.is_active) {
throw new Error('USER_NOT_FOUND');
}
const matches = await bcrypt.compare(currentPassword || '', userRecord.password_hash);
if (!matches) {
throw new Error('INVALID_CURRENT_PASSWORD');
}
const passwordHash = await this.hashPassword(newPassword);
await AdminUserRepository.updatePassword(userRecord.id, passwordHash, false);
return {
id: userRecord.id,
username: userRecord.username,
role: userRecord.role,
requiresPasswordChange: false
};
}
async hashPassword(password) {
return bcrypt.hash(password, DEFAULT_SALT_ROUNDS);
}
async verifyCredentials(username, password) {
const normalizedUsername = (username || '').trim().toLowerCase();
const user = await AdminUserRepository.getByUsername(normalizedUsername);
if (!user || !user.is_active) {
return null;
}
const matches = await bcrypt.compare(password || '', user.password_hash);
if (!matches) {
return null;
}
await AdminUserRepository.recordSuccessfulLogin(user.id);
return {
id: user.id,
username: user.username,
role: user.role,
requiresPasswordChange: Boolean(user.requires_password_change)
};
}
generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
startSession(req, user) {
const csrfToken = this.generateCsrfToken();
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
requiresPasswordChange: user.requiresPasswordChange || false
};
req.session.csrfToken = csrfToken;
return csrfToken;
}
async destroySession(req) {
return new Promise((resolve, reject) => {
if (!req.session) {
return resolve();
}
req.session.destroy((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}
module.exports = new AdminAuthService();

View File

@ -1,12 +1,20 @@
const cron = require('node-cron');
const GroupCleanupService = require('./GroupCleanupService');
const TelegramNotificationService = require('./TelegramNotificationService');
class SchedulerService {
constructor() {
this.tasks = [];
this.telegramService = new TelegramNotificationService();
}
start() {
// Don't start scheduler in test mode
if (process.env.NODE_ENV === 'test') {
console.log('[Scheduler] Skipped in test mode');
return;
}
console.log('[Scheduler] Starting scheduled tasks...');
// Cleanup-Job: Jeden Tag um 10:00 Uhr
@ -24,7 +32,35 @@ class SchedulerService {
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
if (process.env.NODE_ENV === 'development') {
@ -44,6 +80,42 @@ class SchedulerService {
console.log('[Scheduler] Manual cleanup triggered...');
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();

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

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

View File

@ -0,0 +1,48 @@
const { getRequest } = require('../testServer');
const { getAdminSession } = require('../utils/adminSession');
describe('Admin Auth Middleware', () => {
describe('Without Session', () => {
it('should reject requests without session cookie', async () => {
const response = await getRequest()
.get('/api/admin/deletion-log')
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('reason', 'SESSION_REQUIRED');
});
});
describe('With Valid Session', () => {
let adminSession;
beforeAll(async () => {
adminSession = await getAdminSession();
});
it('should allow access with valid session', async () => {
const response = await adminSession.agent
.get('/api/admin/deletion-log')
.expect(200);
expect(response.body).toHaveProperty('success');
});
it('should allow access to multiple admin endpoints', async () => {
const endpoints = [
'/api/admin/deletion-log',
'/api/admin/rate-limiter/stats',
'/api/admin/management-audit',
'/api/admin/groups'
];
for (const endpoint of endpoints) {
const response = await adminSession.agent
.get(endpoint)
.expect(200);
expect(response.body).toBeDefined();
}
});
});
});

View File

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

View File

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

View File

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

View File

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

4
backend/tests/env.js Normal file
View File

@ -0,0 +1,4 @@
process.env.NODE_ENV = 'test';
process.env.PORT = process.env.PORT || '5001';
process.env.ADMIN_SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'test-session-secret';
process.env.SKIP_PREVIEW_GENERATION = process.env.SKIP_PREVIEW_GENERATION || '1';

View File

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

View File

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

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

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

View File

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

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,148 @@
const { requireAdminAuth } = require('../../src/middlewares/auth');
const AdminAuthService = require('../../src/services/AdminAuthService');
const AdminUserRepository = require('../../src/repositories/AdminUserRepository');
const dbManager = require('../../src/database/DatabaseManager');
describe('Auth Middleware Unit Test (Session based)', () => {
let req, res, next;
beforeEach(() => {
req = { session: null };
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
locals: {}
};
next = jest.fn();
});
test('should reject when no session exists', () => {
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Zugriff verweigert',
reason: 'SESSION_REQUIRED'
})
);
expect(next).not.toHaveBeenCalled();
});
test('should reject when session user is missing', () => {
req.session = {};
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
);
expect(next).not.toHaveBeenCalled();
});
test('should reject non-admin roles', () => {
req.session = { user: { id: 1, role: 'viewer' } };
requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'SESSION_REQUIRED' })
);
expect(next).not.toHaveBeenCalled();
});
test('should pass through for admin sessions and expose user on locals', () => {
const adminUser = { id: 1, role: 'admin', username: 'testadmin' };
req.session = { user: adminUser };
requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.locals.adminUser).toEqual(adminUser);
});
});
describe('AdminAuthService', () => {
beforeEach(async () => {
await dbManager.run('DELETE FROM admin_users');
});
afterEach(async () => {
await dbManager.run('DELETE FROM admin_users');
});
test('needsInitialSetup reflects admin count', async () => {
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(true);
await AdminAuthService.createInitialAdmin({
username: 'existing',
password: 'SuperSecure123!'
});
await expect(AdminAuthService.needsInitialSetup()).resolves.toBe(false);
});
test('createInitialAdmin validates input and detects completed setup', async () => {
await expect(
AdminAuthService.createInitialAdmin({ username: '', password: 'SuperSecure123!' })
).rejects.toThrow('USERNAME_REQUIRED');
await expect(
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'short' })
).rejects.toThrow('PASSWORD_TOO_WEAK');
await AdminAuthService.createInitialAdmin({ username: 'seed', password: 'SuperSecure123!' });
await expect(
AdminAuthService.createInitialAdmin({ username: 'admin', password: 'SuperSecure123!' })
).rejects.toThrow('SETUP_ALREADY_COMPLETED');
});
test('createInitialAdmin persists normalized admin when setup allowed', async () => {
const result = await AdminAuthService.createInitialAdmin({
username: 'TestAdmin',
password: 'SuperSecure123!'
});
expect(result.username).toBe('testadmin');
expect(result.role).toBe('admin');
const stored = await AdminUserRepository.getByUsername('testadmin');
expect(stored).toMatchObject({ username: 'testadmin', role: 'admin', is_active: 1 });
});
test('verifyCredentials handles missing users and password mismatches', async () => {
await expect(AdminAuthService.verifyCredentials('admin', 'pw')).resolves.toBeNull();
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
await AdminUserRepository.createAdminUser({
username: 'admin',
passwordHash: hash,
role: 'admin',
requiresPasswordChange: false
});
await expect(AdminAuthService.verifyCredentials('admin', 'wrong')).resolves.toBeNull();
});
test('verifyCredentials returns sanitized user for valid credentials', async () => {
const hash = await AdminAuthService.hashPassword('SuperSecure123!');
await AdminUserRepository.createAdminUser({
username: 'admin',
passwordHash: hash,
role: 'admin',
requiresPasswordChange: true
});
const result = await AdminAuthService.verifyCredentials('admin', 'SuperSecure123!');
expect(result).toEqual({
id: expect.any(Number),
username: 'admin',
role: 'admin',
requiresPasswordChange: true
});
});
});

View File

@ -0,0 +1,153 @@
const fs = require('fs');
const GroupRepository = require('../../src/repositories/GroupRepository');
const DeletionLogRepository = require('../../src/repositories/DeletionLogRepository');
const GroupCleanupService = require('../../src/services/GroupCleanupService');
describe('GroupCleanupService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getDaysUntilDeletion', () => {
const NOW = new Date('2024-01-10T00:00:00Z');
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(NOW);
});
afterAll(() => {
jest.useRealTimers();
});
it('returns remaining days when future deletion date is ahead', () => {
const days = GroupCleanupService.getDaysUntilDeletion(new Date('2024-01-05T00:00:00Z'));
expect(days).toBe(2);
});
it('clamps negative differences to zero', () => {
const days = GroupCleanupService.getDaysUntilDeletion(new Date('2023-12-01T00:00:00Z'));
expect(days).toBe(0);
});
});
describe('deletePhysicalFiles', () => {
it('counts successful deletions and ignores missing files', async () => {
const unlinkMock = jest.spyOn(fs.promises, 'unlink');
unlinkMock
.mockResolvedValueOnce()
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce();
const result = await GroupCleanupService.deletePhysicalFiles([
{ file_path: 'images/one.jpg', preview_path: 'previews/one.jpg' },
{ file_path: 'images/two.jpg', preview_path: 'previews/two.jpg' }
]);
expect(result).toEqual({ success: 2, failed: 1 });
expect(unlinkMock).toHaveBeenCalledTimes(4);
});
});
describe('findGroupsForDeletion', () => {
it('fetches unapproved groups older than default threshold', async () => {
const groups = [{ group_id: 'abc' }];
const findSpy = jest
.spyOn(GroupRepository, 'findUnapprovedGroupsOlderThan')
.mockResolvedValue(groups);
const result = await GroupCleanupService.findGroupsForDeletion();
expect(findSpy).toHaveBeenCalledWith(GroupCleanupService.CLEANUP_DAYS);
expect(result).toBe(groups);
});
});
describe('deleteGroupCompletely', () => {
it('returns null when statistics are missing', async () => {
jest.spyOn(GroupRepository, 'getGroupStatistics').mockResolvedValue(null);
const deleteSpy = jest.spyOn(GroupRepository, 'deleteGroupCompletely').mockResolvedValue({});
const result = await GroupCleanupService.deleteGroupCompletely('missing-group');
expect(result).toBeNull();
expect(deleteSpy).not.toHaveBeenCalled();
});
it('removes group, files and logs deletion', async () => {
jest.spyOn(GroupRepository, 'getGroupStatistics').mockResolvedValue({
groupId: 'group-1',
year: 2024,
imageCount: 3,
uploadDate: '2024-01-01',
totalFileSize: 1234
});
jest.spyOn(GroupRepository, 'deleteGroupCompletely').mockResolvedValue({
imagePaths: [{ file_path: 'images/a.jpg', preview_path: 'previews/a.jpg' }],
deletedImages: 3
});
const deleteFilesSpy = jest
.spyOn(GroupCleanupService, 'deletePhysicalFiles')
.mockResolvedValue({ success: 2, failed: 0 });
const logSpy = jest.spyOn(GroupCleanupService, 'logDeletion').mockResolvedValue();
const result = await GroupCleanupService.deleteGroupCompletely('group-1');
expect(deleteFilesSpy).toHaveBeenCalledWith([{ file_path: 'images/a.jpg', preview_path: 'previews/a.jpg' }]);
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({ groupId: 'group-1', imageCount: 3, totalFileSize: 1234 })
);
expect(result).toEqual({ groupId: 'group-1', imagesDeleted: 3, filesDeleted: 2 });
});
});
describe('logDeletion', () => {
it('swallows repository errors so cleanup continues', async () => {
jest.spyOn(DeletionLogRepository, 'createDeletionEntry').mockRejectedValue(new Error('db down'));
await expect(
GroupCleanupService.logDeletion({ groupId: 'g1', year: 2024, imageCount: 1, uploadDate: '2024-01-01' })
).resolves.toBeUndefined();
});
});
describe('performScheduledCleanup', () => {
it('returns early when there is nothing to delete', async () => {
const findSpy = jest.spyOn(GroupCleanupService, 'findGroupsForDeletion').mockResolvedValue([]);
const result = await GroupCleanupService.performScheduledCleanup();
expect(findSpy).toHaveBeenCalled();
expect(result).toEqual({
success: true,
deletedGroups: 0,
message: 'No groups to delete'
});
});
it('keeps track of successes and failures', async () => {
const findSpy = jest
.spyOn(GroupCleanupService, 'findGroupsForDeletion')
.mockResolvedValue([{ group_id: 'g1' }, { group_id: 'g2' }]);
const deleteSpy = jest
.spyOn(GroupCleanupService, 'deleteGroupCompletely')
.mockResolvedValueOnce()
.mockRejectedValueOnce(new Error('boom'));
const result = await GroupCleanupService.performScheduledCleanup();
expect(findSpy).toHaveBeenCalled();
expect(deleteSpy).toHaveBeenCalledTimes(2);
expect(result.success).toBe(true);
expect(result.deletedGroups).toBe(1);
expect(result.failedGroups).toBe(1);
expect(result.duration).toBeDefined();
});
});
});

View File

@ -0,0 +1,112 @@
const { formatGroupListRow, formatGroupDetail } = require('../../src/utils/groupFormatter');
describe('groupFormatter', () => {
describe('formatGroupListRow', () => {
it('maps snake_case columns to camelCase dto', () => {
const row = {
group_id: 'foo',
year: 2024,
title: 'Title',
description: 'Desc',
name: 'Alice',
upload_date: '2024-01-01',
approved: 1,
image_count: '5',
preview_image: 'path/to/thumb.jpg'
};
expect(formatGroupListRow(row)).toEqual({
groupId: 'foo',
year: 2024,
title: 'Title',
description: 'Desc',
name: 'Alice',
uploadDate: '2024-01-01',
approved: true,
imageCount: 5,
previewImage: 'path/to/thumb.jpg'
});
});
it('provides sane defaults when optional values missing', () => {
const row = {
group_id: 'bar',
year: 2023,
title: 'Other',
description: null,
name: null,
upload_date: '2023-12-24',
approved: 0,
image_count: null,
preview_image: undefined
};
expect(formatGroupListRow(row)).toEqual({
groupId: 'bar',
year: 2023,
title: 'Other',
description: null,
name: null,
uploadDate: '2023-12-24',
approved: false,
imageCount: 0,
previewImage: null
});
});
});
describe('formatGroupDetail', () => {
it('maps nested image rows and flags', () => {
const group = {
group_id: 'foo',
year: 2024,
title: 'Title',
description: 'Desc',
name: 'Alice',
upload_date: '2024-01-01',
approved: 0,
display_in_workshop: 1,
consent_timestamp: null
};
const images = [
{
id: 1,
file_name: 'one.png',
original_name: 'one.png',
file_path: 'images/one.png',
preview_path: null,
upload_order: 1,
file_size: null,
mime_type: 'image/png',
image_description: 'desc'
}
];
expect(formatGroupDetail(group, images)).toEqual({
groupId: 'foo',
year: 2024,
title: 'Title',
description: 'Desc',
name: 'Alice',
uploadDate: '2024-01-01',
approved: false,
display_in_workshop: true,
consent_timestamp: null,
images: [
{
id: 1,
fileName: 'one.png',
originalName: 'one.png',
filePath: 'images/one.png',
previewPath: null,
uploadOrder: 1,
fileSize: null,
mimeType: 'image/png',
imageDescription: 'desc'
}
],
imageCount: 1
});
});
});
});

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

View File

@ -0,0 +1,73 @@
const { getAgent } = require('../testServer');
const DEFAULT_CREDENTIALS = {
username: 'testadmin',
password: 'SuperSicher123!'
};
let cachedSession = null;
async function initializeSession() {
const agent = getAgent();
const statusResponse = await agent
.get('/auth/setup/status')
.expect(200);
let csrfToken;
if (statusResponse.body.needsSetup) {
const setupResponse = await agent
.post('/auth/setup/initial-admin')
.send(DEFAULT_CREDENTIALS)
.expect(201);
csrfToken = setupResponse.body?.csrfToken;
} else {
const loginResponse = await agent
.post('/auth/login')
.send(DEFAULT_CREDENTIALS);
if (loginResponse.status === 409 && loginResponse.body?.error === 'SETUP_REQUIRED') {
// Edge case: setup status may lag behind perform setup now
const setupResponse = await agent
.post('/auth/setup/initial-admin')
.send(DEFAULT_CREDENTIALS)
.expect(201);
csrfToken = setupResponse.body?.csrfToken;
} else if (loginResponse.status !== 200) {
throw new Error(
`Failed to log in test admin (status ${loginResponse.status}): ${JSON.stringify(loginResponse.body)}`
);
} else {
csrfToken = loginResponse.body?.csrfToken;
}
}
if (!csrfToken) {
const csrfResponse = await agent.get('/auth/csrf-token').expect(200);
csrfToken = csrfResponse.body.csrfToken;
}
cachedSession = { agent, csrfToken };
return cachedSession;
}
async function getAdminSession() {
if (cachedSession) {
return cachedSession;
}
return initializeSession();
}
async function refreshCsrfToken() {
const session = await getAdminSession();
const csrfResponse = await session.agent.get('/auth/csrf-token').expect(200);
session.csrfToken = csrfResponse.body.csrfToken;
return session.csrfToken;
}
module.exports = {
getAdminSession,
refreshCsrfToken
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

24
dev.sh
View File

@ -5,7 +5,7 @@
set -euo pipefail
echo "🚀 Starting Project Image Uploader - Development Environment"
echo "Starting Project Image Uploader - Development Environment"
echo " Frontend: http://localhost:3000"
echo " Backend: http://localhost:5001"
echo ""
@ -18,23 +18,23 @@ if docker compose ps | grep -q "image-uploader-frontend.*Up"; then
fi
# Start development environment
echo "📦 Starting development containers..."
echo "Starting development containers..."
docker compose -f docker/dev/docker-compose.yml up -d
echo ""
echo "Development environment started!"
echo "Development environment started!"
echo ""
echo "📊 Container Status:"
echo "Container Status:"
docker compose -f docker/dev/docker-compose.yml ps
echo ""
echo "🔗 Access URLs:"
echo " 📱 Frontend (Development): http://localhost:3000"
echo " 🔧 Backend API (Development): http://localhost:5001"
echo "Access URLs:"
echo " Frontend (Development): http://localhost:3000"
echo " Backend API (Development): http://localhost:5001"
echo ""
echo "📝 Useful Commands:"
echo " 📋 Show logs: docker compose -f docker/dev/docker-compose.yml logs -f"
echo " 🛑 Stop: docker compose -f docker/dev/docker-compose.yml down"
echo " 🔄 Restart: docker compose -f docker/dev/docker-compose.yml restart"
echo " 🏗️ Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache"
echo "Useful Commands:"
echo " Show logs: docker compose -f docker/dev/docker-compose.yml logs -f"
echo " Stop: docker compose -f docker/dev/docker-compose.yml down"
echo " Restart: docker compose -f docker/dev/docker-compose.yml restart"
echo " Rebuild: docker compose -f docker/dev/docker-compose.yml build --no-cache"
echo ""

View File

@ -10,6 +10,22 @@ NODE_ENV=development
# Port for the backend server
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)
# DB_HOST=localhost
# DB_PORT=3306

View File

@ -6,7 +6,4 @@
# Production: http://backend:5000 (container-to-container)
API_URL=http://backend:5000
# Client URL - the URL where users access the frontend
# Development: http://localhost:3000 (dev server)
# Production: http://localhost (nginx on port 80)
CLIENT_URL=http://localhost
# Public/Internal host separation (optional)

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/ .
# Copy development environment configuration
COPY docker/dev/backend/config/.env ./.env
# Note: Environment variables are set via docker-compose.yml
# No .env file needed in the image
# Expose port
EXPOSE 5000

View File

@ -17,8 +17,9 @@ services:
- dev_frontend_node_modules:/app/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
- API_URL=http://backend-dev:5000
- CLIENT_URL=http://localhost:3000
- API_URL=http://localhost:5001
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
depends_on:
- backend-dev
networks:
@ -26,6 +27,7 @@ services:
backend-dev:
container_name: image-uploader-backend-dev
user: "1000:1000"
build:
context: ../../
dockerfile: docker/dev/backend/Dockerfile
@ -37,10 +39,34 @@ services:
- dev_backend_node_modules:/usr/src/app/node_modules
environment:
- 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:
- dev-internal
command: [ "npm", "run", "server" ]
sqliteweb:
image: tomdesinto/sqliteweb
ports:
- "8080:8080"
volumes:
- ../../backend/src/data:/usr/src/app/src/data:ro # identischer Host-Pfad wie im Backend
command: /usr/src/app/src/data/db/image_uploader.db
networks:
- dev-internal
depends_on:
- backend-dev
networks:
dev-internal:
driver: bridge

View File

@ -13,9 +13,9 @@ WORKDIR /app
# Copy package files first to leverage Docker cache for npm install
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 ./.env
# Note: ENV variables are set via docker-compose.yml, not from .env file
# Make env.sh executable
RUN chmod +x ./env.sh
@ -23,9 +23,6 @@ RUN chmod +x ./env.sh
# Copy nginx configuration for development
COPY docker/dev/frontend/nginx.conf /etc/nginx/conf.d/default.conf
# Copy htpasswd file for authentication
COPY docker/dev/frontend/config/htpasswd /etc/nginx/.htpasswd
# Make /app owned by the non-root user, then run npm as that user so
# node_modules are created with the correct owner and we avoid an expensive
# recursive chown later.

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
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
# List of environment variables to export (add more as needed)
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Read each environment variable and add to config
for varname in $ENV_VARS; do
# Get value from environment using indirect expansion
value="${!varname}"
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
fi
done
echo "}" >> ./env-config.js

View File

@ -1,132 +1,35 @@
server {
listen 80;
# Allow large uploads (50MB)
client_max_body_size 50M;
# Allow large uploads (100MB for batch uploads)
client_max_body_size 100M;
# API proxy to backend-dev service
location /upload {
# ========================================
# Backend API Routes (all under /api/)
# ========================================
# Basierend auf routeMappings.js:
# - /api/upload, /api/download, /api/groups (Public API)
# - /api/manage/* (Management Portal, Token-basiert)
# - /api/admin/* (Admin/Moderation, Bearer Token)
# - /api/system/migration/* (System API)
location /api/ {
proxy_pass http://backend-dev:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for API too
client_max_body_size 50M;
}
# API routes for new multi-upload features
location /api/upload {
proxy_pass http://backend-dev:5000/upload;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for batch upload
# Large uploads für Batch-Upload
client_max_body_size 100M;
}
# API - Download original images
location /api/download {
proxy_pass http://backend-dev:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Preview/thumbnail images (optimized for gallery views)
location /api/previews {
proxy_pass http://backend-dev:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups (NO PASSWORD PROTECTION)
location /api/groups {
proxy_pass http://backend-dev:5000/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Social Media Consent Management (NO PASSWORD PROTECTION)
location /api/social-media {
proxy_pass http://backend-dev:5000/api/social-media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Management Portal (NO PASSWORD PROTECTION - Token-based auth)
location /api/manage {
proxy_pass http://backend-dev:5000/api/manage;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin API routes (NO password protection - protected by /moderation page access)
location /api/admin {
proxy_pass http://backend-dev:5000/api/admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ========================================
# Frontend Routes (React Dev Server)
# ========================================
# Protected API - Moderation API routes (password protected) - must come before /groups
location /moderation/groups {
auth_basic "Restricted Area - Moderation API";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://backend-dev:5000/moderation/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups API routes (NO PASSWORD PROTECTION)
location ~ ^/groups/[a-zA-Z0-9_-]+(/.*)?$ {
proxy_pass http://backend-dev:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /download {
proxy_pass http://backend-dev:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend page - Groups overview (NO PASSWORD PROTECTION) - React Dev Server
location /groups {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Protected routes - Moderation (password protected) - React Dev Server
# Moderation route proxy (session-protected in app layer)
location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@ -136,18 +39,16 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket support for hot reloading (React Dev Server)
# WebSocket support for React Hot Reloading
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Frontend files - React Dev Server
# All other routes React Dev Server (Client-side routing)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

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 production environment configuration
COPY docker/prod/backend/config/.env ./.env
# COPY docker/prod/backend/config/.env ./.env
# Create data directories for file storage
RUN mkdir -p src/data/images src/data/previews src/data/groups

View File

@ -15,7 +15,9 @@ services:
- backend
environment:
- API_URL=http://backend:5000
- CLIENT_URL=http://localhost
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
networks:
- npm-nw
- prod-internal
@ -33,7 +35,27 @@ services:
networks:
- prod-internal
environment:
- REMOVE_IMAGES=false
- NODE_ENV=production
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions
# ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen)
- ADMIN_SESSION_COOKIE_SECURE=true
# Host Configuration (Public/Internal Separation)
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true
- PUBLIC_UPLOAD_RATE_LIMIT=20
- PUBLIC_UPLOAD_RATE_WINDOW=3600000
# Trust nginx-proxy-manager (1 hop)
- TRUST_PROXY_HOPS=1
# Telegram Bot Configuration (optional)
- TELEGRAM_ENABLED=${TELEGRAM_ENABLED:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
- TELEGRAM_SEND_TEST_ON_START=false
networks:
npm-nw:

View File

@ -14,19 +14,16 @@ FROM nginx:stable-alpine
RUN rm -rf /etc/nginx/conf.d
COPY docker/prod/frontend/nginx.conf /etc/nginx/nginx.conf
# Copy htpasswd file for authentication
COPY docker/prod/frontend/config/htpasswd /etc/nginx/.htpasswd
# Static build
COPY --from=build /app/build /usr/share/nginx/html
# Default port exposure
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
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
RUN apk add --no-cache bash

View File

@ -7,23 +7,18 @@ touch ./env-config.js
# Add assignment
echo "window._env_ = {" >> ./env-config.js
# Read each line in .env file
# Each line represents key=value pairs
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
# List of environment variables to export (add more as needed)
ENV_VARS="API_URL PUBLIC_HOST INTERNAL_HOST"
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Read each environment variable and add to config
for varname in $ENV_VARS; do
# Get value from environment using indirect expansion
value="${!varname}"
# Append configuration property to JS file
echo " $varname: \"$value\"," >> ./env-config.js
done < .env
# Only add if value exists
if [ -n "$value" ]; then
echo " $varname: \"$value\"," >> ./env-config.js
fi
done
echo "}" >> ./env-config.js

View File

@ -38,90 +38,22 @@ http {
# Allow large uploads (50MB)
client_max_body_size 50M;
# API proxy to image-uploader-backend service
location /upload {
# Generic API proxy for all backend endpoints under /api/
# This mirrors the dev setup: forward everything under /api/ to the backend service
location /api/ {
proxy_pass http://image-uploader-backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for API too
client_max_body_size 50M;
}
# API routes for new multi-upload features
location /api/upload {
proxy_pass http://image-uploader-backend:5000/upload;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Allow large uploads for batch upload
# Allow large uploads for batch upload endpoints
client_max_body_size 100M;
}
# API - Download original images
location /api/download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Preview/thumbnail images (optimized for gallery views)
location /api/previews {
proxy_pass http://image-uploader-backend:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups (NO PASSWORD PROTECTION)
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Social Media Consent Management (NO PASSWORD PROTECTION)
location /api/social-media {
proxy_pass http://image-uploader-backend:5000/api/social-media;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Management Portal (NO PASSWORD PROTECTION - Token-based auth)
location /api/manage {
proxy_pass http://image-uploader-backend:5000/api/manage;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin API routes (NO password protection - protected by /moderation page access)
location /api/admin {
proxy_pass http://image-uploader-backend:5000/api/admin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Protected API - Moderation API routes (password protected) - must come before /groups
location /moderation/groups {
auth_basic "Restricted Area - Moderation API";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://image-uploader-backend:5000/moderation/groups;
# Admin auth/session endpoints (login/logout/setup/csrf)
location /auth/ {
proxy_pass http://image-uploader-backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -156,11 +88,8 @@ http {
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive" always;
}
# Protected routes - Moderation (password protected)
# Moderation UI (session-protected within the app)
location /moderation {
auth_basic "Restricted Area - Moderation";
auth_basic_user_file /etc/nginx/.htpasswd;
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;

View File

@ -1,269 +0,0 @@
# 🎯 Implementation Report: Drag-and-Drop Reordering Feature
## 📊 Executive Summary
**Feature**: Persistentes Drag-and-Drop Reordering für Bildreihenfolgen in Gruppen
**Status**: ✅ **COMPLETE** - Alle 9 Aufgaben erfolgreich implementiert
**Implementation Time**: ~8 Stunden (1 Session)
**Performance**: 10 Bilder reordering in 0,148 Sekunden
**Technology Stack**: @dnd-kit/core, Express.js, SQLite, React 18.3.1
## 🏆 Key Achievements
### ✅ Must-Have Features Delivered
- **Drag-and-Drop Interface**: Vollständig implementiert mit @dnd-kit/core
- **Touch-Support**: Mobile-friendly drag handles für Tablet/Smartphone
- **Visual Feedback**: Drag states, drop zones, loading indicators
- **Persistierung**: Robuste SQL-Transaktionen mit Rollback-Support
- **Admin-Only Access**: Reordering nur für Admins in ModerationGroupImagesPage
- **Slideshow Integration**: Automatische Berücksichtigung neuer upload_order
### 🚀 Technical Highlights
- **Performance**: 0,148s für 10-Bild Reordering (unter Backend-Target von 500ms)
- **Error Handling**: Comprehensive validation und graceful degradation
- **Optimistic Updates**: Sofortige UI-Updates mit Rollback bei API-Fehlern
- **Touch-Friendly**: Always-visible drag handles für Mobile-Nutzung
- **Type Safety**: PropTypes validation für alle neuen Komponenten
## 📁 File Changes Summary
### 🆕 New Files Created (5 files)
```
backend/src/routes/reorder.js - API endpoint for reordering
frontend/src/services/reorderService.js - HTTP client service
frontend/src/hooks/useReordering.js - DnD logic hook
docs/FEATURE_PLAN-reordering.md - Complete implementation plan
docs/REORDERING_IMPLEMENTATION_REPORT.md - This report
```
### 🔄 Modified Files (6 files)
```
backend/src/repositories/GroupRepository.js - Added updateImageOrder()
backend/src/routes/index.js - Route registration
frontend/src/Components/ComponentUtils/ImageGallery.js - DnD context
frontend/src/Components/ComponentUtils/ImageGalleryCard.js - Drag handles
frontend/src/Pages/ModerationGroupImagesPage.js - Admin reordering
frontend/src/Pages/PublicGroupImagesPage.js - Disabled for public
```
### 📦 Dependencies Added
```json
{
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2"
}
```
## 🔧 Technical Implementation Details
### Backend Architecture
```
PUT /api/groups/:groupId/reorder
├── Validation Layer
│ ├── Group existence check
│ ├── Image ownership validation
│ └── Complete image set verification
├── Business Logic Layer
│ ├── SQL transaction handling
│ ├── Batch upload_order updates
│ └── Error rollback mechanisms
└── Response Layer
├── Success/error status codes
├── Detailed error messages
└── Operation logging
```
### Frontend Architecture
```
ModerationGroupImagesPage (Admin)
├── ImageGallery (enableReordering=true)
│ ├── DndContext Provider
│ ├── SortableContext Container
│ └── Collision Detection
├── ImageGalleryCard (Sortable Items)
│ ├── useSortable Hook
│ ├── Drag Handles (always visible)
│ └── Visual State Indicators
└── useReordering Hook
├── Optimistic State Updates
├── API Communication
└── Error Recovery
```
## 📊 Testing Results
### ✅ API Testing (curl)
```bash
# Success Cases
✅ Normal reordering: [328,327,326,325,324,323] -> Success
✅ Reverse order: [323,324,325,326,327,328] -> Success
✅ Complex reorder: [320,319,318,317,316,315,314,313,312,311] -> Success
# Error Cases
✅ Invalid group ID -> 404 "Group not found"
✅ Invalid image IDs -> 400 "Invalid image IDs: 999, 888, 777"
✅ Missing image IDs -> 400 "Missing image IDs: 325, 326, 327, 328"
```
### ✅ Performance Testing
```
Small Group (6 images): ~0.033s response time
Medium Group (10 images): ~0.148s response time
Database queries: <10ms per operation
Memory usage: No leaks detected
```
### ✅ Browser Compatibility Testing
```
✅ Chrome 120+ - Full DnD support
✅ Firefox 120+ - Full DnD support
✅ Safari 17+ - Full DnD support (expected)
✅ Mobile browsers - Touch-friendly drag handles
```
### ✅ User Experience Testing
```
✅ Visual feedback during drag operations
✅ Loading states with SweetAlert2 notifications
✅ Error messages with automatic rollback
✅ Intuitive drag handles always visible
✅ Smooth animations and transitions
```
## 🔐 Security & Permissions
### Access Control Implementation
- **Admin-Only Feature**: Reordering only available on `/moderation/groups/:id` routes
- **Public Users**: Explicitly disabled reordering on `/groups/:id` routes
- **API Security**: Group ownership validation prevents unauthorized reordering
- **Input Validation**: Complete sanitization of image IDs and group IDs
### Data Integrity
- **ACID Transactions**: All database operations are atomic
- **Validation**: Complete image set required for reordering
- **Rollback Mechanisms**: Failed operations don't leave partial state
- **Audit Trail**: All reordering operations are logged
## 🚀 Deployment Readiness
### ✅ Production Checklist
- [x] All code committed to version control
- [x] No console errors or warnings
- [x] Error handling covers all edge cases
- [x] Performance meets requirements (<500ms)
- [x] Mobile compatibility verified
- [x] Security validation complete
- [x] Documentation updated
- [x] Backward compatibility maintained
### 📋 Rollback Plan
```bash
# If issues arise, feature can be disabled by:
1. Set enableReordering={false} in ModerationGroupImagesPage
2. Comment out reorder route registration
3. Existing upload_order values remain unchanged
4. No data migration needed for rollback
```
## 🎯 Business Value Delivered
### For Administrators
- **Improved Content Curation**: Admins can now arrange image galleries for optimal storytelling
- **Better Slideshow Control**: Custom sequencing enhances presentation quality
- **Time Savings**: Drag-and-drop is faster than delete/re-upload workflows
- **Professional Presentation**: Curated order improves public-facing content
### For End Users
- **Better User Experience**: Slideshows now follow logical, curated sequences
- **Improved Navigation**: Image galleries have intentional, meaningful order
- **Professional Quality**: Content appears more polished and intentional
### Technical Benefits
- **Maintainable Code**: Clean separation of concerns and well-documented APIs
- **Scalable Architecture**: Can handle growth from 10 to 100+ images per group
- **Robust Error Handling**: Graceful degradation ensures system stability
- **Mobile-Ready**: Touch-first design supports growing mobile usage
## 📈 Metrics & Success Criteria
### ✅ Functional Metrics (All Met)
- 100% of drag-and-drop operations persist correctly ✅
- <500ms latency for reordering API calls (achieved: 148ms)
- 0 data losses through race conditions ✅
- All error cases handled gracefully ✅
### ✅ User Experience Metrics (All Met)
- Intuitive operation without documentation ✅
- Mobile-friendly touch interface ✅
- Visual feedback throughout interaction ✅
- Error recovery with clear messaging ✅
### ✅ Technical Metrics (All Met)
- Minimal bundle size impact (<100KB added)
- No performance regression in existing features ✅
- Cross-browser compatibility (Chrome, Firefox, Safari) ✅
- Clean, maintainable code architecture ✅
## 🔮 Future Enhancement Opportunities
### Short-Term (Next Sprint)
- [ ] Keyboard shortcuts (Arrow keys for reordering)
- [ ] Bulk selection and multi-item drag
- [ ] Undo/Redo functionality with history
- [ ] Real-time collaboration indicators
### Medium-Term (Next Quarter)
- [ ] Advanced sorting options (by date, name, size)
- [ ] Drag-and-drop between different groups
- [ ] Grid layout preservation during reordering
- [ ] Advanced touch gestures (swipe to reorder)
### Long-Term (Future Versions)
- [ ] AI-suggested optimal ordering
- [ ] Custom sorting rules and templates
- [ ] Integration with external DAM systems
- [ ] Advanced permission models (group-specific editors)
## 💡 Lessons Learned
### Technical Insights
1. **@dnd-kit/core** proved superior to react-beautiful-dnd for modern React
2. **Touch-first design** is crucial - always-visible drag handles work better than hover-only
3. **Optimistic updates** greatly improve perceived performance
4. **SQL transactions** are essential for data integrity in batch operations
### Process Improvements
1. **Comprehensive planning** (FEATURE_PLAN-reordering.md) accelerated development
2. **API-first development** enabled parallel frontend/backend work
3. **Progressive testing** (API → Components → Integration) caught issues early
4. **Performance testing** during development prevented late-stage optimization
### User Experience Learnings
1. **Visual feedback** is critical for drag-and-drop confidence
2. **Error recovery** must be automatic and transparent
3. **Mobile compatibility** requires different UX patterns than desktop
4. **Loading states** are essential for operations that touch the database
## 🎉 Conclusion
The Drag-and-Drop Reordering feature has been **successfully implemented** and is **ready for production deployment**. All original requirements have been met or exceeded, with additional enhancements for mobile usability and error resilience.
The implementation demonstrates:
- **Technical Excellence**: Clean, maintainable code with comprehensive error handling
- **User-Centered Design**: Intuitive interface with excellent mobile support
- **Performance Optimization**: Sub-200ms operations with efficient database queries
- **Security Best Practices**: Admin-only access with complete input validation
**Recommendation**: Deploy to production immediately. The feature adds significant value for content creators while maintaining system stability and security.
---
**Implementation Date**: January 2025
**Development Time**: ~8 hours
**Lines of Code Added**: ~1,200 LOC
**Files Modified**: 11 files
**New Dependencies**: 3 packages (@dnd-kit ecosystem)
**Test Coverage**: 100% manual testing, API validation complete
**Ready for Production**: ✅ YES

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

10
frontend/.env.example Normal file
View File

@ -0,0 +1,10 @@
# Frontend Environment Variables
# Currently no frontend-specific secrets are required. Add overrides (e.g. public API URLs)
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
# Example:
# 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

Some files were not shown because too many files have changed in this diff Show More