Compare commits

...

3 Commits

Author SHA1 Message Date
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
24 changed files with 2253 additions and 77 deletions

View File

@ -1,5 +1,104 @@
# Changelog
## [Unreleased] - Branch: feature/public-internal-hosts
### 🌐 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)
---
## [Unreleased] - Branch: feature/security
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,14 @@ Es soll unterschieden werden, welche Funktionen der App abhängig von der aufger
- `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar.
- `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend.
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet.
Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet (dns challenge letsencrypt).
Es wäre optional möglich, das public-Frontend extern zu hosten und nur die entsprechenden API-Endpunkte öffentlich verfügbar zu machen.
## Ziele
- Sicherheit: Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen.
- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar.
- Flexibilität: Support sowohl für ein und denselben Host (Host-Header-Check) als auch für separat gehostetes public-Frontend.
- 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)
@ -80,23 +79,21 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA
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: `public.example.com` und `public.lan.example.com`.
- Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. BeispielAntwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`.
2. Host-Check vs. zusätzliche Checks
- Doku: AdminAPI ist bereits serverseitig per BearerToken (`ADMIN_API_KEY`) geschützt. ManagementAPI nutzt UUIDToken mit RateLimits (10 req/h) und BruteForceSchutz.
- Doku: AdminAPI ist bereits serverseitig per Admin Login geschützt. ManagementAPI nutzt UUIDToken mit RateLimits (10 req/h) und BruteForceSchutz.
- Empfehlung: Primär HostHeader (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für AdminAPIs die Kombination aus BearerToken + HostCheck (defense in depth). Bitte bestätigen, ob IPWhitelist gewünscht ist.
3. Externes Hosting des publicFrontends
- Doku: Assets und Server liegen standardmäßig lokal (backend `src/data/images` / `src/data/previews`). Externes Hosting ist nicht Teil der Standardkonfiguration.
- Empfehlung: Behalte Assets intern (Standard). Wenn Du extern hosten willst, müssen CORS, Allowlist und ggf. signierte URLs implementiert werden. Bestätige, ob externes Hosting geplant ist.
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.
- ~~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.
@ -104,7 +101,7 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA
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?
- 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.

View File

@ -442,6 +442,157 @@ 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)
---
## Nützliche Befehle
```bash

View File

@ -22,6 +22,17 @@ This project extends the original [Image-Uploader by vallezw](https://github.com
### 🆕 Latest Features (November 2025)
- **🌐 Public/Internal Host Separation** (Nov 25):
- Subdomain-based feature separation for production deployment
- Public host (`deinprojekt.hobbyhimmel.de`): Upload + UUID Management only
- Internal host (`deinprojekt.lan.hobbyhimmel.de`): Full admin access
- Frontend code splitting with React.lazy() for optimized bundle size
- Backend API protection via hostGate middleware
- Rate limiting: 20 uploads/hour on public host
- Audit log tracking with source host information
- Complete local testing support via /etc/hosts entries
- Zero configuration overhead for single-host deployments
- **🧪 Comprehensive Test Suite** (Nov 16):
- 45 automated tests covering all API endpoints (100% passing)
- Jest + Supertest integration testing framework

View File

@ -322,6 +322,9 @@
}
}
},
"429": {
"description": "Too Many Requests"
},
"500": {
"description": "Server error during upload"
}

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

@ -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,114 @@
/**
* Host Gate Middleware
* Blockiert geschützte API-Routen für public Host
* Erlaubt nur Upload + Management für public Host
*
* Erkennt Host via X-Forwarded-Host (nginx-proxy-manager) oder Host Header
*/
const PUBLIC_HOST = process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
const ENABLE_HOST_RESTRICTION = process.env.ENABLE_HOST_RESTRICTION !== 'false';
// Debug: Log configuration on module load (development only)
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
console.log('🔧 hostGate config:', { PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION });
}
// Routes die NUR für internal Host erlaubt sind
const INTERNAL_ONLY_ROUTES = [
'/api/admin',
'/api/groups',
'/api/slideshow',
'/api/migration',
'/api/moderation',
'/api/reorder',
'/api/batch-upload',
'/api/social-media',
'/api/auth/login', // Admin Login nur internal
'/api/auth/logout',
'/api/auth/session'
];
// Routes die für public Host erlaubt sind
const PUBLIC_ALLOWED_ROUTES = [
'/api/upload',
'/api/manage',
'/api/previews',
'/api/consent',
'/api/social-media/platforms' // Nur Plattformen lesen (für Consent-Badges im UUID Management)
];
/**
* Middleware: Host-basierte Zugriffskontrolle
* @param {Object} req - Express Request
* @param {Object} res - Express Response
* @param {Function} next - Next Middleware
*/
const hostGate = (req, res, next) => {
// Feature disabled only when explicitly set to false OR in test environment without explicit enable
const isTestEnv = process.env.NODE_ENV === 'test';
const explicitlyEnabled = process.env.ENABLE_HOST_RESTRICTION === 'true';
const explicitlyDisabled = process.env.ENABLE_HOST_RESTRICTION === 'false';
// Skip restriction if:
// - Explicitly disabled, OR
// - Test environment AND not explicitly enabled
if (explicitlyDisabled || (isTestEnv && !explicitlyEnabled)) {
req.isPublicHost = false;
req.isInternalHost = true;
req.requestSource = 'internal';
return next();
}
// Get host from X-Forwarded-Host (nginx-proxy-manager) or Host header
const forwardedHost = req.get('x-forwarded-host');
const hostHeader = req.get('host');
const host = forwardedHost || hostHeader || '';
const hostname = host.split(':')[0]; // Remove port if present
// Determine if request is from public or internal host
req.isPublicHost = hostname === PUBLIC_HOST;
req.isInternalHost = hostname === INTERNAL_HOST || hostname === 'localhost' || hostname === '127.0.0.1';
// Log host detection for debugging
if (process.env.NODE_ENV !== 'production') {
console.log(`🔍 Host Detection: ${hostname}${req.isPublicHost ? 'PUBLIC' : 'INTERNAL'}`);
}
// If public host, check if route is allowed
if (req.isPublicHost) {
const path = req.path;
// Check if explicitly allowed (z.B. /api/social-media/platforms)
const isExplicitlyAllowed = PUBLIC_ALLOWED_ROUTES.some(route =>
path === route || path.startsWith(route + '/')
);
if (isExplicitlyAllowed) {
// Erlaubt - kein Block
req.requestSource = 'public';
return next();
}
// Check if route is internal-only
const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route =>
path.startsWith(route)
);
if (isInternalOnly) {
console.warn(`🚫 Public host blocked access to: ${path} (Host: ${hostname})`);
return res.status(403).json({
error: 'Not available on public host',
message: 'This endpoint is only available on the internal network'
});
}
}
// Add request source context for audit logging
req.requestSource = req.isPublicHost ? 'public' : 'internal';
next();
};
module.exports = hostGate;

View File

@ -2,6 +2,7 @@ const express = require("express");
const fileUpload = require("express-fileupload");
const cors = require("./cors");
const session = require("./session");
const hostGate = require("./hostGate");
const applyMiddlewares = (app) => {
app.use(fileUpload());
@ -9,6 +10,8 @@ const applyMiddlewares = (app) => {
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

@ -19,6 +19,15 @@ const RATE_LIMIT = {
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

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

@ -6,6 +6,7 @@ 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();
@ -15,7 +16,7 @@ router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR)
// Serve preview images via URL /previews but store files under data/previews
router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
router.post('/upload', async (req, res) => {
router.post('/upload', publicUploadLimiter, async (req, res) => {
/*
#swagger.tags = ['Upload']
#swagger.summary = 'Upload a single image and create a new group'

View File

@ -20,6 +20,10 @@ 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() {
@ -95,8 +99,11 @@ class Server {
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 && swaggerDocument) {
this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
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;
}

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

@ -20,6 +20,8 @@ services:
- CHOKIDAR_USEPOLLING=true
- API_URL=http://localhost:5001
- CLIENT_URL=http://localhost:3000
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
depends_on:
- backend-dev
networks:
@ -40,6 +42,11 @@ services:
- ./backend/config/.env:/usr/src/app/.env:ro
environment:
- NODE_ENV=development
- PUBLIC_HOST=public.test.local
- INTERNAL_HOST=internal.test.local
- ENABLE_HOST_RESTRICTION=true
- TRUST_PROXY_HOPS=0
- PUBLIC_UPLOAD_RATE_LIMIT=20
networks:
- dev-internal
command: [ "npm", "run", "server" ]

View File

@ -16,6 +16,8 @@ services:
environment:
- API_URL=http://backend:5000
- CLIENT_URL=http://localhost
- PUBLIC_HOST=deinprojekt.hobbyhimmel.de
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
networks:
- npm-nw
@ -40,6 +42,14 @@ services:
- 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=deinprojekt.hobbyhimmel.de
- INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de
- ENABLE_HOST_RESTRICTION=true
- PUBLIC_UPLOAD_RATE_LIMIT=20
- PUBLIC_UPLOAD_RATE_WINDOW=3600000
# Trust nginx-proxy-manager (1 hop)
- TRUST_PROXY_HOPS=1

View File

@ -0,0 +1,6 @@
# Development Environment Variables
# Allow access from custom hostnames (public.test.local, internal.test.local)
DANGEROUSLY_DISABLE_HOST_CHECK=true
# Use 0.0.0.0 to allow external access
HOST=0.0.0.0

View File

@ -4,3 +4,7 @@
# via `REACT_APP_*` variables only if they are safe to expose to browsers.
# 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

View File

@ -1,31 +1,115 @@
import React, { lazy, Suspense } from 'react';
import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx';
import { getHostConfig } from './Utils/hostDetection.js';
// Pages
// Always loaded (public + internal)
import MultiUploadPage from './Components/Pages/MultiUploadPage';
import SlideshowPage from './Components/Pages/SlideshowPage';
import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage';
import ModerationGroupsPage from './Components/Pages/ModerationGroupsPage';
import ModerationGroupImagesPage from './Components/Pages/ModerationGroupImagesPage';
import PublicGroupImagesPage from './Components/Pages/PublicGroupImagesPage';
import ManagementPortalPage from './Components/Pages/ManagementPortalPage';
import FZF from './Components/Pages/404Page.js'
import NotFoundPage from './Components/Pages/404Page.js';
// Lazy loaded (internal only) - Code Splitting für Performance
const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage'));
const GroupsOverviewPage = lazy(() => import('./Components/Pages/GroupsOverviewPage'));
const PublicGroupImagesPage = lazy(() => import('./Components/Pages/PublicGroupImagesPage'));
const ModerationGroupsPage = lazy(() => import('./Components/Pages/ModerationGroupsPage'));
const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/ModerationGroupImagesPage'));
/**
* Protected Route Component
* Redirects to upload page if accessed from public host
*/
const ProtectedRoute = ({ children }) => {
const hostConfig = getHostConfig();
if (hostConfig.isPublic) {
// Redirect to upload page - feature not available on public
return <Navigate to="/" replace />;
}
return children;
};
/**
* Loading Fallback für Code Splitting
*/
const LoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
flexDirection: 'column',
gap: '1rem'
}}>
<div className="spinner"></div>
<p>Lädt...</p>
</div>
);
function App() {
const hostConfig = getHostConfig();
return (
<AdminSessionProvider>
<Router>
<Routes>
<Route path="/" exact element={<MultiUploadPage />} />
<Route path="/slideshow" element={<SlideshowPage />} />
<Route path="/groups/:groupId" element={<PublicGroupImagesPage />} />
<Route path="/groups" element={<GroupsOverviewPage />} />
<Route path="/moderation" exact element={<ModerationGroupsPage />} />
<Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} />
<Route path="*" element={<FZF />} />
</Routes>
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* Public Routes - immer verfügbar */}
<Route path="/" element={<MultiUploadPage />} />
<Route path="/manage/:token" element={<ManagementPortalPage />} />
{/* Internal Only Routes - nur auf internal host geladen */}
{hostConfig.isInternal && (
<>
<Route
path="/slideshow"
element={
<ProtectedRoute>
<SlideshowPage />
</ProtectedRoute>
}
/>
<Route
path="/groups/:groupId"
element={
<ProtectedRoute>
<PublicGroupImagesPage />
</ProtectedRoute>
}
/>
<Route
path="/groups"
element={
<ProtectedRoute>
<GroupsOverviewPage />
</ProtectedRoute>
}
/>
<Route
path="/moderation"
element={
<ProtectedRoute>
<ModerationGroupsPage />
</ProtectedRoute>
}
/>
<Route
path="/moderation/groups/:groupId"
element={
<ProtectedRoute>
<ModerationGroupImagesPage />
</ProtectedRoute>
}
/>
</>
)}
{/* 404 / Not Found */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</Router>
</AdminSessionProvider>
);

View File

@ -29,12 +29,29 @@ function UploadSuccessDialog({ open, onClose, groupId, uploadCount }) {
const [copied, setCopied] = useState(false);
const handleCopyGroupId = () => {
navigator.clipboard.writeText(groupId).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(groupId).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
} else {
// Fallback: Erstelle temporäres Input-Element
try {
const input = document.createElement('input');
input.value = groupId;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
return (

File diff suppressed because one or more lines are too long

View File

@ -335,7 +335,18 @@ function MultiUploadPage() {
}}
onClick={() => {
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
navigator.clipboard.writeText(link);
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link);
} else {
// Fallback: Erstelle temporäres Input-Element
const input = document.createElement('input');
input.value = link;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
}}
>
📋 Kopieren

View File

@ -0,0 +1,94 @@
/**
* Host Detection Utility
*
* Erkennt, ob App auf public oder internal Host läuft
* Basiert auf window.location.hostname + env-config
*
* @module Utils/hostDetection
*/
/**
* Hole Host-Konfiguration und Feature-Flags
* @returns {Object} Host-Config mit Feature-Flags
*/
export const getHostConfig = () => {
const hostname = window.location.hostname;
// Hole Hosts aus Runtime-Config (wird von env.sh beim Container-Start gesetzt)
const publicHost = window._env_?.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
const internalHost = window._env_?.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
// Bestimme Host-Typ
const isPublic = hostname === publicHost;
const isInternal = hostname === internalHost || hostname === 'localhost' || hostname === '127.0.0.1';
// Feature Flags basierend auf Host
return {
hostname,
publicHost,
internalHost,
isPublic,
isInternal,
// Feature Flags
canAccessAdmin: isInternal,
canAccessSlideshow: isInternal,
canAccessGroups: isInternal,
canAccessModeration: isInternal,
canAccessReorder: isInternal,
canAccessBatchUpload: isInternal,
canAccessSocialMedia: isInternal,
canAccessMigration: isInternal,
// Immer erlaubt (public + internal)
canUpload: true,
canManageByUUID: true
};
};
/**
* Prüft, ob App auf public Host läuft
* @returns {boolean} True wenn public Host
*/
export const isPublicHost = () => {
return getHostConfig().isPublic;
};
/**
* Prüft, ob App auf internal Host läuft
* @returns {boolean} True wenn internal Host
*/
export const isInternalHost = () => {
return getHostConfig().isInternal;
};
/**
* Hole spezifisches Feature-Flag
* @param {string} featureName - Name des Features (z.B. 'canAccessAdmin')
* @returns {boolean} True wenn Feature erlaubt
*/
export const canAccessFeature = (featureName) => {
const config = getHostConfig();
return config[featureName] || false;
};
/**
* Debug-Funktion: Logge Host-Config in Console
* Nur in Development
*/
export const logHostConfig = () => {
if (process.env.NODE_ENV === 'development') {
const config = getHostConfig();
console.log('🔍 Host Configuration:', {
hostname: config.hostname,
isPublic: config.isPublic,
isInternal: config.isInternal,
features: {
admin: config.canAccessAdmin,
slideshow: config.canAccessSlideshow,
groups: config.canAccessGroups,
moderation: config.canAccessModeration
}
});
}
};