From be7f4aa92ba6d19d0d38c38084e01ecebfa50d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 00:34:41 +0200 Subject: [PATCH] mobile + docs: themeColor light-mode, Coach FAB safe-area, Sonner system theme, self-hosting docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit plus the first two docs A8 audit identified as missing. Mobile-deep: - `viewport.themeColor` was pinned to `#282a36` (dark). The Android URL bar + iOS PWA status bar stayed dark even when the user was on the light theme. Replaced with the dual-media-query form: `#ffffff` under `(prefers-color-scheme: light)`, `#282a36` under `(prefers-color-scheme: dark)`. Browser chrome now matches the active palette. - Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones the home-indicator safe area is ~34 px so the FAB collided with the bottom-nav. Moved to `bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it floats above the inset-aware nav. - Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode page the toast contrasted badly against the surface. Switched to `theme="system"` so Sonner reads the resolved theme from the root element (set pre-paint by the inline theme script in layout.tsx) instead of pinning dark. Self-hosting docs: - `docs/self-hosting/getting-started.md` — step-by-step from `git clone` to first measurement, covering the 3-secret env setup (post-SESSION_SECRET drop), `docker compose up -d`, the first-user-becomes-admin contract, and the next-step pointers. - `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx Proxy Manager, Coolify, and bare-Nginx config examples. Closes the README link at line 107 that previously punted to the docs site. --- docs/self-hosting/getting-started.md | 154 +++++++++++++++ docs/self-hosting/reverse-proxy.md | 198 +++++++++++++++++++ src/app/layout.tsx | 9 +- src/components/insights/layout-coach-fab.tsx | 8 +- src/components/ui/sonner.tsx | 6 +- 5 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 docs/self-hosting/getting-started.md create mode 100644 docs/self-hosting/reverse-proxy.md diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md new file mode 100644 index 000000000..e51803bb0 --- /dev/null +++ b/docs/self-hosting/getting-started.md @@ -0,0 +1,154 @@ +# Getting started — self-host HealthLog + +This walkthrough takes a fresh host from `git clone` to a running +instance with a working first user. About fifteen minutes if Docker is +already installed, longer if a TLS-fronted public hostname is in scope +(see the reverse-proxy guide for that). + +## Prerequisites + +- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml` + pulls a multi-arch image from GitHub Container Registry (linux/amd64 + + linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work + unchanged. +- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with + measurement history; the Apple Health import worker can briefly + hold a 1.5 GB upload in `/tmp` while it parses. +- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever + integration endpoints you plan to enable (Withings, OpenAI, etc.). +- **Optional but recommended:** a reverse proxy that terminates TLS + and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx + Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`). + +## 1. Clone the repository + +```bash +git clone https://github.com/MBombeck/HealthLog.git +cd HealthLog +cp .env.example .env +``` + +`.env.example` is the canonical reference for every env var the app +reads. Anything you do not uncomment falls back to a sane default or +disables the optional subsystem. + +## 2. Generate the three required secrets + +HealthLog needs three secrets before it will boot. Generate them with +`openssl` and append to `.env`: + +```bash +echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env +echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env +``` + +| Variable | Purpose | Rotation cost | +| -------- | ------- | ------------- | +| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update | +| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` | +| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token | + +Then open `.env` and confirm `DATABASE_URL` includes the password you +just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`) +references the literal string `CHANGE-ME`; replace it with the value +you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute +explicitly. + +## 3. Bring the stack up + +```bash +docker compose up -d +``` + +Compose starts two services: + +- `db` — PostgreSQL 16 with a named volume for persistence. +- `app` — HealthLog itself, listening on `localhost:3000`. The image + runs `prisma migrate deploy` on boot so the schema lands + automatically; subsequent restarts no-op when the database is + already current. + +The first boot pulls the image (a few hundred megabytes) and runs +migrations. Once `docker compose logs -f app` shows the Next.js +banner, the instance is live. + +## 4. Create the first user + +Open `http://localhost:3000` in a browser. The registration flow asks +for a username, email, and password. **The first user that registers +is promoted to admin automatically.** Every subsequent registration +creates a regular user. + +After the first user lands, you can: + +- Open `/admin` to review the worker status, audit log, and any + optional channels you want to configure (Telegram bot, ntfy, Web + Push VAPID keys, AI fallback key). +- Open `/measurements` and add a weight or blood pressure to confirm + the data path is wired end-to-end. + +## 5. Point the instance at a public hostname + +Local-only is fine for a single-user trial. For anything more — and +to unlock OAuth callbacks, webhook deliveries, and Web Push — set the +public URL pair before restarting: + +```env +NEXT_PUBLIC_APP_URL="https://your-instance.example.com" +APP_URL="https://your-instance.example.com" +``` + +Both vars must match. `NEXT_PUBLIC_APP_URL` lands in the client bundle +at build time and drives every absolute URL the UI emits (OAuth +redirects, share links, manifest icons). `APP_URL` is the server-side +mirror used by background jobs that have no request context. + +Restart the app container after editing: + +```bash +docker compose up -d +``` + +The pre-built image reads both values at startup; no rebuild needed. + +## 6. Pick a reverse-proxy + +The bundled stack exposes plain HTTP on `localhost:3000`. Production +deployments terminate TLS in front of the container. The +`docs/self-hosting/reverse-proxy.md` guide carries copy-paste +configurations for Caddy, Traefik, Nginx Proxy Manager, Coolify, and +bare Nginx — including the `X-Forwarded-For` chain shape that the +rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how +many hops to read back. + +## 7. Where to go next + +| Next step | File | +| --------- | ---- | +| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | +| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | +| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | +| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | +| Withings device sync | `docs/integrations/withings.md` | +| Apple Health `export.zip` import | `docs/integrations/apple-health.md` | +| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` | + +## Troubleshooting + +- **`app` container exits with a Prisma migration error.** The + Postgres healthcheck races the migration on slow disks. Run + `docker compose restart app` once the database has stabilised — + migrations are idempotent. +- **First-user registration shows "registration disabled".** The + bundled image ships with registration enabled; the toggle lives in + `app_settings.singleton.registrationEnabled`. The admin panel can + flip it back if a previous owner closed it. +- **Service unreachable on `localhost:3000`.** Check `docker compose + ps` — the `app` healthcheck polls `/api/version` every 30 seconds + and will mark the container unhealthy if Postgres is still + starting. Tail `docker compose logs db` to confirm Postgres came up + cleanly. +- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and + `NEXT_PUBLIC_APP_URL` both point at the public hostname, not at + `localhost`, and restart `app` after the change. diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md new file mode 100644 index 000000000..0ccca29cd --- /dev/null +++ b/docs/self-hosting/reverse-proxy.md @@ -0,0 +1,198 @@ +# Reverse proxy configuration + +The bundled `docker-compose.yml` exposes HealthLog on plain HTTP at +`localhost:3000`. Production deployments front the container with a +reverse proxy that terminates TLS, sets the forwarded headers the +rate-limiter trusts, and preserves the WebSocket upgrade path the +streaming Coach surface depends on. + +This guide gives copy-paste blocks for five common fronts. Pick one; +the env-var pairing is identical across them. + +## Pre-flight — env vars the proxy interacts with + +| Variable | Required for | Notes | +| -------- | ------------ | ----- | +| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves | +| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match | +| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop | + +Set the URL pair to the public origin (`https://your-instance.example.com`) +and restart the `app` container. The image reads both at startup; no +rebuild needed. + +### `TRUST_PROXY_HOPS` — what to set it to + +`src/lib/api-response.ts` (`getClientIp`) reads the `X-Forwarded-For` +chain from the **right**, counting back `TRUST_PROXY_HOPS` entries. +This guards against a client rotating `X-Forwarded-For` per request +to defeat IP-based rate limits. + +| Topology | `TRUST_PROXY_HOPS` | +| -------- | ------------------ | +| App is internet-facing with no proxy | `0` (XFF ignored entirely) | +| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) | +| Cloudflare → your proxy → app | `2` | +| Cloudflare → Coolify-Tunnel → Coolify → app | `3` | + +A misconfigured count logs a one-shot warning to stderr and collapses +every anonymous caller into one shared rate-limit bucket. Match the +value to the actual hop count or set it to `0` and let `x-real-ip` +drive the IP resolution. + +## Caddy + +`Caddyfile`: + +``` +your-instance.example.com { + encode zstd gzip + reverse_proxy localhost:3000 { + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up Host {host} + flush_interval -1 + } +} +``` + +Caddy handles the WebSocket upgrade automatically. `flush_interval -1` +disables response buffering so the streaming insight events surface +without batching delays. With Caddy as the only hop, leave +`TRUST_PROXY_HOPS=1`. + +## Traefik + +`docker-compose.override.yml` snippet: + +```yaml +services: + app: + labels: + - "traefik.enable=true" + - "traefik.http.routers.healthlog.rule=Host(`your-instance.example.com`)" + - "traefik.http.routers.healthlog.entrypoints=websecure" + - "traefik.http.routers.healthlog.tls.certresolver=letsencrypt" + - "traefik.http.services.healthlog.loadbalancer.server.port=3000" + - "traefik.http.middlewares.healthlog-headers.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.routers.healthlog.middlewares=healthlog-headers" +``` + +Traefik forwards `X-Forwarded-For` by default and respects the WebSocket +upgrade. Pair with `TRUST_PROXY_HOPS=1`. If Traefik sits behind +Cloudflare, set it to `2` so the rate-limiter reads the real client IP +that Cloudflare placed in the chain. + +## Nginx Proxy Manager + +In the NPM UI: + +1. **Proxy Hosts → Add Proxy Host.** +2. **Domain Names:** `your-instance.example.com`. +3. **Scheme:** `http`; **Forward Hostname / IP:** the Docker host or + internal hostname; **Forward Port:** `3000`. +4. Enable **Websockets Support** (required for streaming Coach + responses). +5. **SSL tab:** request a Let's Encrypt certificate, force SSL. +6. **Advanced tab — Custom Nginx Configuration:** + + ```nginx + 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_set_header Host $host; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + ``` + + The 300-second read timeout matters for long-running insight + generations — anything shorter will cut streamed responses mid-flight. + +Pair with `TRUST_PROXY_HOPS=1`. + +## Coolify + +Coolify's bundled Traefik handles most of this. The pieces that need +operator attention: + +1. **Application → Domains:** set `https://your-instance.example.com`. + Coolify provisions the certificate and adds the Traefik labels. +2. **Application → Environment Variables:** set + `NEXT_PUBLIC_APP_URL`, `APP_URL`, and any optional secrets + (`WITHINGS_*`, `APNS_*`, `LOKI_*`). +3. **Application → General → Pull policy:** `always`. The bundled + `docker-compose.yml` declares this explicitly (v1.4.34.2 fix) so + Coolify re-checks the GHCR digest on every redeploy instead of + reusing the cached `:latest` layer. +4. **Notifications → Webhook:** point at `/api/internal/deploy-webhook` + with the `X-Deploy-Webhook-Secret` header set to + `DEPLOY_WEBHOOK_SECRET`. The app gates the endpoint with a + timing-safe compare against that env var. + +Coolify-Tunnel adds one hop to the chain; pair with +`TRUST_PROXY_HOPS=2`. If you front Coolify itself with Cloudflare, +bump to `3`. + +## Bare Nginx + +`/etc/nginx/sites-available/healthlog`: + +```nginx +upstream healthlog { + server 127.0.0.1:3000; + keepalive 16; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name your-instance.example.com; + + ssl_certificate /etc/letsencrypt/live/your-instance.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-instance.example.com/privkey.pem; + + client_max_body_size 1600M; # Apple Health export.zip cap is 1.5 GB + + location / { + proxy_pass http://healthlog; + 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_read_timeout 300s; + proxy_buffering off; + } +} + +server { + listen 80; + listen [::]:80; + server_name your-instance.example.com; + return 301 https://$host$request_uri; +} +``` + +`client_max_body_size 1600M` is the load-bearing setting if you plan +to support Apple Health `export.zip` uploads — the app caps the +upload at 1.5 GB and Nginx will reject the body before the app sees +it otherwise. `proxy_buffering off` keeps streamed insight events +flowing without buffer pauses. Pair with `TRUST_PROXY_HOPS=1`. + +## Verifying the proxy chain + +Once the proxy is in front, hit `https://your-instance.example.com/api/version` +from outside the host and check `docker compose logs app` for the +request. The wide-event log line should carry a non-null client IP +that matches your real source address. A `null` IP or a single +`unknown` rate-limit bucket means `TRUST_PROXY_HOPS` is set higher +than the actual proxy hop count, or the proxy is dropping the +`X-Forwarded-For` header entirely. + +The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but +X-Forwarded-For carried M entries` confirms a mismatch. Adjust the +env var down to the actual chain length and restart. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1cd15bc2f..ea7f03881 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -79,7 +79,14 @@ export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", - themeColor: "#282a36", + // Match the browser chrome (Android URL bar, iOS PWA status bar) to + // the active palette. The hex values are the resolved background of + // `--background` from `app/globals.css` for each theme so the bar + // edge never seams against the page on cold paint. + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#282a36" }, + ], }; // Inline script to apply theme before first paint (prevents FOUC) diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 999e3ab50..e3d8f23c9 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -61,7 +61,13 @@ export function LayoutCoachFab() { aria-hidden={tooltipActive ? true : undefined} tabIndex={tooltipActive ? -1 : undefined} className={cn( - "fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg", + // Sit above the 64 px bottom-nav + the iPhone home-indicator + // safe-area inset. `calc(env(safe-area-inset-bottom,0)+5rem)` + // keeps the FAB clear of the nav on notched iPhones (where the + // inset is ~34 px) without forcing extra padding into the + // layout grid. + "fixed right-4 z-40 h-12 rounded-full px-4 shadow-lg", + "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]", "from-dracula-purple to-dracula-pink bg-gradient-to-br text-white", "hover:from-dracula-purple/90 hover:to-dracula-pink/90", "lg:hidden", diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 4806db889..80acff7d0 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -12,7 +12,11 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { return ( ,