From f0c3cacb069dab72b601405f058b629bca66649c Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 5 Jun 2026 18:04:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20Plausible=20=C3=BCber=20PLAU?= =?UTF-8?q?SIBLE=5FENABLED=20und=20PLAUSIBLE=5FHOST=20steuerbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-Konfiguration im Frontend-Container trennt Prod und Staging; Staging deaktiviert Analytics standardmäßig. Co-authored-by: Cursor --- .env.example | 6 ++++ client/Dockerfile | 11 +++--- client/docker-entrypoint.sh | 26 ++++++++++++++ client/index.html | 2 +- client/nginx.conf | 53 ++-------------------------- client/nginx.conf.template | 51 ++++++++++++++++++++++++++ client/public/plausible-bootstrap.js | 25 +++++++++++++ client/vite.config.ts | 29 +++++++++++++++ docker-compose.staging.yml | 3 ++ docker-compose.yml | 3 ++ docs/deployment/npm-security.md | 13 +++++-- docs/deployment/staging.md | 4 +++ docs/plausible-events.md | 13 +++++-- 13 files changed, 178 insertions(+), 61 deletions(-) create mode 100755 client/docker-entrypoint.sh create mode 100644 client/nginx.conf.template create mode 100644 client/public/plausible-bootstrap.js 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