diff --git a/.env.example b/.env.example
index de4eefd..2850ee5 100755
--- a/.env.example
+++ b/.env.example
@@ -56,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
+
+# Plausible Analytics (frontend container — see docs/plausible-events.md)
+# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
+# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
+PLAUSIBLE_ENABLED=true
+PLAUSIBLE_HOST=https://plausible.elpatron.me
diff --git a/client/Dockerfile b/client/Dockerfile
index 7b8deb4..b4f9213 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
-# Copy custom Nginx configuration
-COPY client/nginx.conf /etc/nginx/conf.d/default.conf
+RUN apk add --no-cache gettext
+
+COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
+COPY client/docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder
COPY --from=builder /app/dist .
-# Expose HTTP port
EXPOSE 80
-# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
diff --git a/client/docker-entrypoint.sh b/client/docker-entrypoint.sh
new file mode 100755
index 0000000..0b7ff3a
--- /dev/null
+++ b/client/docker-entrypoint.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+set -eu
+
+PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
+PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
+PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
+
+case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
+ true|1|yes)
+ PLAUSIBLE_ENABLED_JSON=true
+ PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
+ ;;
+ *)
+ PLAUSIBLE_ENABLED_JSON=false
+ PLAUSIBLE_CSP=""
+ ;;
+esac
+
+export PLAUSIBLE_CSP
+envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
+
+cat > /usr/share/nginx/html/runtime-config.json <
+
@@ -38,7 +39,6 @@
-
Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)
diff --git a/client/nginx.conf b/client/nginx.conf
index dca9df7..e170a55 100644
--- a/client/nginx.conf
+++ b/client/nginx.conf
@@ -1,51 +1,2 @@
-server {
- listen 80;
- server_name localhost;
- client_max_body_size 50M;
-
- # Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
-
- # Service worker and app shell must revalidate so PWA updates are detected
- location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
- root /usr/share/nginx/html;
- add_header Cache-Control "no-cache, no-store, must-revalidate" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
- }
-
- location = /index.html {
- root /usr/share/nginx/html;
- add_header Cache-Control "no-cache, must-revalidate" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
- }
-
- location / {
- root /usr/share/nginx/html;
- index index.html index.htm;
- try_files $uri $uri/ /index.html;
- }
-
- location /api/ {
- proxy_pass http://backend:5000/api/;
- 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;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_cache_bypass $http_upgrade;
- }
-}
+# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
+# Local Docker Compose uses the template via client/Dockerfile entrypoint.
diff --git a/client/nginx.conf.template b/client/nginx.conf.template
new file mode 100644
index 0000000..f9b4b1d
--- /dev/null
+++ b/client/nginx.conf.template
@@ -0,0 +1,51 @@
+server {
+ listen 80;
+ server_name localhost;
+ client_max_body_size 50M;
+
+ # Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
+
+ # Service worker and app shell must revalidate so PWA updates are detected
+ location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
+ root /usr/share/nginx/html;
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
+ }
+
+ location = /index.html {
+ root /usr/share/nginx/html;
+ add_header Cache-Control "no-cache, must-revalidate" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
+ }
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api/ {
+ proxy_pass http://backend:5000/api/;
+ 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;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ }
+}
diff --git a/client/public/plausible-bootstrap.js b/client/public/plausible-bootstrap.js
new file mode 100644
index 0000000..6e1c3db
--- /dev/null
+++ b/client/public/plausible-bootstrap.js
@@ -0,0 +1,25 @@
+/**
+ * Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
+ * data-domain is always the current hostname (prod vs staging).
+ */
+(function () {
+ function load(cfg) {
+ if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
+ var host = String(cfg.plausibleHost).replace(/\/$/, '')
+ if (!host) return
+ var s = document.createElement('script')
+ s.defer = true
+ s.dataset.domain = window.location.hostname
+ s.src = host + '/js/script.tagged-events.js'
+ document.head.appendChild(s)
+ }
+
+ fetch('/runtime-config.json', { cache: 'no-store' })
+ .then(function (r) {
+ return r.ok ? r.json() : null
+ })
+ .then(load)
+ .catch(function () {
+ /* analytics optional */
+ })
+})()
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 36fa92b..da78095 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
}
}
+function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
+ const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
+ const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
+ const plausibleEnabled = !['false', '0', 'no'].includes(flag)
+ return { plausibleEnabled, plausibleHost: host }
+}
+
+function runtimeConfigPlugin(): Plugin {
+ return {
+ name: 'runtime-config',
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.url !== '/runtime-config.json') return next()
+ res.setHeader('Content-Type', 'application/json')
+ res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
+ })
+ },
+ writeBundle(options) {
+ const outDir = options.dir ?? resolve(__dirname, 'dist')
+ writeFileSync(
+ resolve(outDir, 'runtime-config.json'),
+ `${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
+ )
+ }
+ }
+}
+
// https://vite.dev/config/
export default defineConfig({
+ envDir: resolve(__dirname, '..'),
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
+ runtimeConfigPlugin(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
index 6a267f8..69019e2 100644
--- a/docker-compose.staging.yml
+++ b/docker-compose.staging.yml
@@ -57,6 +57,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-staging-frontend
restart: always
+ environment:
+ PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
+ PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
diff --git a/docker-compose.yml b/docker-compose.yml
index 671d375..45c224d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -58,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend
restart: always
+ environment:
+ PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
+ PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
diff --git a/docs/deployment/npm-security.md b/docs/deployment/npm-security.md
index c45007e..c452b4c 100644
--- a/docs/deployment/npm-security.md
+++ b/docs/deployment/npm-security.md
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
-- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
+- **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
### Plausible Analytics
-Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
+Konfiguration über `.env` (Frontend-Container):
-Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
+```env
+PLAUSIBLE_ENABLED=true
+PLAUSIBLE_HOST=https://plausible.elpatron.me
+```
+
+Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
+
+Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
## Nach Deploy prüfen
diff --git a/docs/deployment/staging.md b/docs/deployment/staging.md
index 5951302..9a13643 100644
--- a/docs/deployment/staging.md
+++ b/docs/deployment/staging.md
@@ -47,6 +47,10 @@ SESSION_SECRET=
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-staging-feedback
+
+# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
+PLAUSIBLE_ENABLED=false
+PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
diff --git a/docs/plausible-events.md b/docs/plausible-events.md
index 6834c50..b6e8336 100644
--- a/docs/plausible-events.md
+++ b/docs/plausible-events.md
@@ -1,12 +1,21 @@
# Plausible Custom Events
-Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
+Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
+
+**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
+
+```env
+PLAUSIBLE_ENABLED=true # Staging: false
+PLAUSIBLE_HOST=https://plausible.elpatron.me
+```
+
+Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup
-1. Script in `client/index.html` (bereits eingebunden)
+1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht