/** * Service Worker for HealthLog PWA. * Handles Web Push notifications and offline caching. */ const CACHE_VERSION = "v1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; // App shell files to precache on install const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"]; // ── Install: precache app shell ────────────────────────────────────────────── self.addEventListener("install", (event) => { event.waitUntil( caches .open(STATIC_CACHE) .then((cache) => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()), ); }); // ── Activate: clean up old caches ──────────────────────────────────────────── self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all( keys .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) .map((k) => caches.delete(k)), ), ) .then(() => self.clients.claim()), ); }); // ── Fetch: strategy per resource type ──────────────────────────────────────── self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url); // Only handle same-origin GET requests if (request.method !== "GET" || url.origin !== self.location.origin) return; // API calls — network only (never serve stale data) if (url.pathname.startsWith("/api/")) return; // Next.js static assets — cache-first (immutable hashed filenames) if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } // Static files (fonts, icons, images) — cache-first with long TTL if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } // HTML pages — network-first with cache fallback if (request.headers.get("accept")?.includes("text/html")) { event.respondWith(networkFirst(request, PAGE_CACHE)); return; } }); /** * Cache-first: serve from cache, fall back to network and cache the response. */ async function cacheFirst(request, cacheName) { const cached = await caches.match(request); if (cached) return cached; const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); trimCache(cacheName, MAX_STATIC_ENTRIES); } return response; } /** * Network-first: try network, fall back to cache. */ async function networkFirst(request, cacheName) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); trimCache(cacheName, MAX_PAGE_ENTRIES); } return response; } catch { const cached = await caches.match(request); if (cached) return cached; // Offline fallback. F-7 (mobile security audit, 2026-05-16): the // previous fallback rendered hard-coded German body copy, which is // wrong for any en/* user reaching it offline. The replacement is // language-neutral — wordmark + the universally understood // "Offline" token + a generic retry hint expressed as an icon // (the round-trip arrow), so the page does the right thing in any // locale without shipping a translation bundle into the worker. return new Response( 'HealthLog — Offline

HealthLog

Offline

', { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" }, }, ); } } /** * Trim cache to maxEntries by removing oldest entries. */ async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); let keys = await cache.keys(); while (keys.length > maxEntries) { await cache.delete(keys[0]); keys = await cache.keys(); } } // ── Push Notifications ─────────────────────────────────────────────────────── self.addEventListener("push", (event) => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { payload = { title: "HealthLog", body: event.data.text() }; } const { title = "HealthLog", body = "", tag = "default", url = "/", } = payload; event.waitUntil( self.registration.showNotification(title, { body, tag, icon: "/logo-192.png", badge: "/logo-192.png", data: { url }, }), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); // F-7 (mobile security audit, 2026-05-16): validate the destination // resolves to the same origin before navigating any client. Push // payloads are VAPID-signed by our own server, but a server-side bug // (or compromised admin issuing pushes) could ship an off-origin URL // into `data.url` and use the focused PWA as a redirect-driven phish. // Reject anything that doesn't match our origin and fall back to "/". const rawUrl = event.notification.data?.url || "/"; let safeUrl = "/"; try { const resolved = new URL(rawUrl, self.location.origin); if (resolved.origin === self.location.origin) { safeUrl = resolved.pathname + resolved.search + resolved.hash; } } catch { safeUrl = "/"; } event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clientList) => { for (const client of clientList) { if (client.url.includes(self.location.origin) && "focus" in client) { client.navigate(safeUrl); return client.focus(); } } return self.clients.openWindow(safeUrl); }), ); });