diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ad89747..e7cc98b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Frontend unit tests run: node tests/frontend/unit.test.mjs - - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI) + - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW) run: | cd tests/frontend if [ -d node_modules ]; then @@ -46,6 +46,7 @@ jobs: node excalidraw-viewer.test.mjs node plugins.test.mjs node ai.test.mjs + node sw.test.mjs else echo "tests/frontend/node_modules missing — installing jsdom" npm install --no-audit --no-fund --silent @@ -53,6 +54,7 @@ jobs: node excalidraw-viewer.test.mjs node plugins.test.mjs node ai.test.mjs + node sw.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index e281865..354ac9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,19 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Corrigé +- **BUG-005 — Chargement mobile incomplet derrière Cloudflare (`og.dracodev.net`)** — + le service worker utilisait une stratégie **cache-first avec un nom de cache fixe** + (`obsigate-v1`) et un précache de chemins erronés (`/frontend/js/…`) : le Cache Storage + du SW (distinct du cache navigateur/Cloudflare) servait un ancien build indéfiniment, + même après purge. Le middleware renvoyait en plus `Cache-Control: public, max-age=31536000, + immutable` sur des assets **non fingerprintés**. Correctifs : `frontend/sw.js` réécrit + (**network-first** pour HTML/JS/CSS, caches versionnés `SW_VERSION`, précache corrigé + `/static/js/…`, `skipWaiting`/`clients.claim`) ; migration ponctuelle `localStorage` + (suppression des anciens caches) en remplacement du kill-switch de session ; en-têtes + `no-cache` sur `/static`, `index.html`, `manifest.json` et pages HTML ; rechargement unique + sur `controllerchange`. Tests : `tests/frontend/sw.test.mjs` + `TestStaticCaching`. + Guide Cloudflare : [docs/PWA_GUIDE.md](./docs/PWA_GUIDE.md). + - **Typage backend (mypy) — 33 erreurs corrigées** (`backend/main.py`, `indexer.py`, `auth/router.py`, `pdf_reader.py`, `export.py`, `bookslm_routes.py`) : annotations de types, gardes `None` sur `get_user()`, `PdfReader: Any` et import `PROVIDERS` manquant dans `main.py` diff --git a/backend/main.py b/backend/main.py index c160d10..3ec2864 100644 --- a/backend/main.py +++ b/backend/main.py @@ -628,9 +628,12 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "worker-src 'self' blob:; " "frame-src 'self' blob:;" ) - # Cache static assets aggressively (fingerprinted by PWA manifest hash) + # Static assets are NOT content-hashed, so they must revalidate: + # ``immutable``/long max-age made Cloudflare and mobile browsers serve + # a stale build for a year (the service worker cache compounded it). + # ``no-cache`` keeps caching but forces revalidation (ETag/Last-Modified). if request.url.path.startswith("/static/"): - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + response.headers["Cache-Control"] = "no-cache" return response @@ -4904,6 +4907,7 @@ async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(requ # --------------------------------------------------------------------------- if FRONTEND_DIR.exists(): + # ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache). app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") @app.get("/sw.js") @@ -4929,7 +4933,7 @@ if FRONTEND_DIR.exists(): return FileResponse( manifest_file, media_type="application/manifest+json", - headers={"Cache-Control": "public, max-age=3600"} + headers={"Cache-Control": "no-cache"} ) raise HTTPException(status_code=404, detail="Manifest not found") @@ -4938,7 +4942,7 @@ if FRONTEND_DIR.exists(): """Serve the minimalist popout page for a specific file.""" popout_file = FRONTEND_DIR / "popout.html" if popout_file.exists(): - return HTMLResponse(content=popout_file.read_text(encoding="utf-8")) + return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Popout template not found") @app.get("/editor-poc") @@ -4946,7 +4950,7 @@ if FRONTEND_DIR.exists(): """Serve the standalone Editor POC page (multi-zone toolbar demo).""" poc_file = FRONTEND_DIR / "editor-poc.html" if poc_file.exists(): - return HTMLResponse(content=poc_file.read_text(encoding="utf-8")) + return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Editor POC not found") @app.get("/admin.html", response_class=HTMLResponse) @@ -4959,7 +4963,7 @@ if FRONTEND_DIR.exists(): """ admin_file = FRONTEND_DIR / "admin.html" if admin_file.exists(): - return HTMLResponse(content=admin_file.read_text(encoding="utf-8")) + return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Admin page not found") @app.get("/{full_path:path}") @@ -4967,5 +4971,5 @@ if FRONTEND_DIR.exists(): """Serve the SPA index.html for all non-API routes.""" index_file = FRONTEND_DIR / "index.html" if index_file.exists(): - return HTMLResponse(content=index_file.read_text(encoding="utf-8")) + return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Frontend not found") diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 52a818d..c2b3032 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -114,6 +114,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | | | | | | | | | | | | *BUG-003* | `mypy` : 33 erreurs de typage (étape CI en mode advisory → bloquante) | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml` | `mypy backend/ --ignore-missing-imports` | Annotations de types, gardes `None` (auth/router), import `PROVIDERS` manquant (bug latent `main.py:4523`), `PdfReader: Any` ; CI mypy rendu bloquant | 0 erreur après correction. `PROVIDERS` non importé → `NameError` avalé par le `except` (le modèle par défaut n'était jamais prépendé). Tests : 728 passed | | *BUG-004* | Lien cassé vers `CONTRIBUTING.md` dans `README.md` | 🟢 corrigé | P3 | 📄 docs | IA | `README.md` | Cliquer le lien « Contributing » | `README.md` : lien → `docs/CONTRIBUTING.md` (+ `DELIVERY_WORKFLOW.md`) ; arbre du projet corrigé | `README.fr.md` pointait déjà correctement vers `./docs/CONTRIBUTING.md` | +| *BUG-005* | Sur mobile via Cloudflare (`og.dracodev.net`), le site ne charge pas complètement même après purge des caches (téléphone + Cloudflare) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py` | Ouvrir `og.dracodev.net` sur mobile (PWA installée), purger les caches, recharger | SW **network-first** pour le code + caches versionnés (`SW_VERSION`) ; précache corrigé (`/static/js/…`) ; migration ponctuelle `localStorage` ; `Cache-Control: no-cache` (le middleware renvoyait `immutable` 1 an sur `/static`) ; tests `tests/frontend/sw.test.mjs` + `TestStaticCaching` | Cause : SW cache-first à nom fixe + en-têtes `immutable` non fingerprinted → ancien build servi indéfiniment (Cache Storage ≠ cache navigateur/CF). Vérifié : 754 tests backend, frontend OK | ### TODOs techniques (améliorations / nouvelles tâches) @@ -135,6 +136,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) | | 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-11 | BUG-005 | Correction | `frontend/sw.js`, `frontend/index.html`, `frontend/js/sync.js`, `backend/main.py`, `.gitea/workflows/ci.yml`, `tests/frontend/sw.test.mjs`, `tests/test_api_main.py` | BUG-005: chargement mobile incomplet via Cloudflare. SW réécrit en **network-first** pour HTML/JS/CSS + caches versionnés ; précache corrigé (`/static/js/…`) ; kill-switch de session remplacé par une migration `localStorage` ponctuelle ; en-têtes `/static` passés de `immutable 1 an` à `no-cache` ; `index.html`/`manifest`/SPA en `no-cache` ; reload unique sur `controllerchange`. Vérifié: 754 tests backend, frontend 9+28+8 OK, ruff/mypy OK. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/PWA_GUIDE.md b/docs/PWA_GUIDE.md index 5db02b1..f37301c 100644 --- a/docs/PWA_GUIDE.md +++ b/docs/PWA_GUIDE.md @@ -94,11 +94,14 @@ Définit les métadonnées de l'application : ### Service Worker (sw.js) -Gère le cache et le mode hors ligne : -- **Cache statique** : Interface et assets -- **Cache dynamique** : Données API (max 50 entrées) -- **Stratégies** : Cache-first et Network-first -- **Nettoyage** : Suppression automatique des anciens caches +Gère le cache et le mode hors ligne (version `SW_VERSION`) : +- **Code (HTML/JS/CSS/manifest)** : **network-first** (cache en secours hors-ligne) +- **API** : network-first (+ cache hors-ligne) +- **Autres assets (images, polices)** : stale-while-revalidate +- **Nettoyage** : purge automatique des caches d'une version antérieure à l'activation + +> Le choix network-first est délibéré : les assets ne sont pas fingerprintés, un +> cache-first servait donc un ancien build indéfiniment sur mobile (BUG-005). ### Icônes PWA @@ -271,7 +274,13 @@ location /sw.js { } location /manifest.json { - add_header Cache-Control "public, max-age=3600"; + add_header Cache-Control "no-cache"; + proxy_pass http://obsigate:8080; +} + +# Les assets /static ne sont PAS fingerprintés : forcer la revalidation. +location /static/ { + add_header Cache-Control "no-cache"; proxy_pass http://obsigate:8080; } ``` @@ -287,6 +296,38 @@ http: Service-Worker-Allowed: "/" ``` +### Cloudflare (BUG-005) + +Derrière Cloudflare, un **service worker cache-first** combiné à un en-tête +`Cache-Control: immutable` sur des assets non fingerprintés peut servir un +ancien build indéfiniment — même après « Purge Everything » : le **Cache Storage +du navigateur (Service Worker) est un stockage séparé** que la purge Cloudflare +et le « vider le cache » du téléphone ne touchent pas. + +Correctifs appliqués côté code (ObsiGate ≥ 2.2.1) : + +- SW **network-first** pour HTML/JS/CSS (cache seulement en secours hors-ligne), + caches versionnés (`SW_VERSION`) purgés à l'activation. +- `Cache-Control: no-cache` sur `/static`, `index.html`, `manifest.json` et les + pages HTML (revalidation ETag, pas de cache long). +- Migration ponctuelle `localStorage` qui supprime les anciens caches sur les + appareils bloqués. + +Réglages Cloudflare recommandés (dashboard → zone `og.dracodev.net`) : + +| Réglage | Valeur | Pourquoi | +|---|---|---| +| **Rocket Loader** | **Désactivé** | Réécrit/defer les scripts et casse les `type="module"`. | +| **Auto Minify (JS/CSS)** | **Désactivé** | Peut casser la syntaxe JS moderne. | +| **Cache Rules / Page Rules** | Pas de « Cache Everything » sur HTML | Sinon Cloudflare ignore l'en-tête `no-cache`. | +| **Browser Cache TTL** | « Respect Existing Headers » | Laisse ObsiGate décider (`no-cache`). | +| **WebSockets** | Activé | Nécessaire aux connexions temps réel (SSE/WS). | +| **Purge Cache** | Après chaque déploiement | Évite un mix ancien/nouveau au premier accès. | + +> Si un appareil reste bloqué malgré tout : sur mobile, désinstaller la PWA +> (ou « Site settings → Clear & reset ») puis rouvrir. La migration +> `obsigate-sw-migration` se charge normalement du nettoyage automatiquement. + ## 🔒 Sécurité ### Content Security Policy diff --git a/frontend/index.html b/frontend/index.html index 57f033a..99834b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5067,8 +5067,27 @@ curl -X POST https://votre-serveur.com/webhook \ diff --git a/frontend/js/sync.js b/frontend/js/sync.js index f0e6bc9..79f1738 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -488,34 +488,48 @@ function _initAIStatus() { // PWA Service Worker Registration // --------------------------------------------------------------------------- function registerServiceWorker() { - if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker - .register("/sw.js") - .then((registration) => { - console.log("[PWA] Service Worker registered successfully:", registration.scope); + if (!("serviceWorker" in navigator)) return; - // Check for updates periodically - setInterval(() => { - registration.update(); - }, 60000); // Check every minute + // Reload once when a new service worker takes control (guarded against + // loops). Only relevant when a worker was already controlling the page. + const hadController = !!navigator.serviceWorker.controller; + let refreshing = false; + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (!hadController || refreshing) return; + refreshing = true; + window.location.reload(); + }); - // Handle service worker updates - registration.addEventListener("updatefound", () => { - const newWorker = registration.installing; - newWorker.addEventListener("statechange", () => { - if (newWorker.state === "installed" && navigator.serviceWorker.controller) { - // New service worker available + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/sw.js") + .then((registration) => { + console.log("[PWA] Service Worker registered successfully:", registration.scope); + + // Check for updates periodically. + setInterval(() => { + registration.update(); + }, 60000); // Check every minute + + // Handle service worker updates: activate the new worker immediately + // so the fresh (network-first) code is served on the next load. + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + if (!newWorker) return; + newWorker.addEventListener("statechange", () => { + if (newWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + newWorker.postMessage({ type: "SKIP_WAITING" }); showUpdateNotification(); } - }); + } }); - }) - .catch((error) => { - console.log("[PWA] Service Worker registration failed:", error); }); - }); - } + }) + .catch((error) => { + console.log("[PWA] Service Worker registration failed:", error); + }); + }); } function showUpdateNotification() { diff --git a/frontend/sw.js b/frontend/sw.js index 5bdb524..6e7a62a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,126 +1,146 @@ -/* ObsiGate — Service Worker (Push notifications + Offline cache) - * Version: 1.0.0 - * Scope: / +/* ObsiGate — Service Worker (PWA offline + push) + * Version: 2 (2026-09-11) + * + * Caching policy (fixes stale-asset loads behind Cloudflare / mobile): + * - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST + * - API : network-first (+ offline) + * - other static (images, fonts…) : stale-while-revalidate + * + * Assets are served without content hashes. The previous "cache-first with a + * fixed cache name" kept an old build forever on mobile: flushing the browser + * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is + * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const CACHE_NAME = 'obsigate-v1'; -const OFFLINE_CACHE = 'obsigate-offline-v1'; +const SW_VERSION = 'v2'; +const CODE_CACHE = `obsigate-code-${SW_VERSION}`; +const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; +const API_CACHE = `obsigate-api-${SW_VERSION}`; +const OFFLINE_URL = '/index.html'; -// Files to cache for offline access +// Core shell precached for offline use. Paths MUST be the real served URLs +// (the app is mounted under /static, not /frontend). const PRECACHE_URLS = [ '/', - '/index.html', + OFFLINE_URL, '/manifest.json', - '/frontend/js/app.js', - '/frontend/js/state.js', - '/frontend/js/auth.js', - '/frontend/js/ui.js', - '/frontend/js/sidebar.js', - '/frontend/js/viewer.js', - '/frontend/js/search.js', - '/frontend/js/config.js', - '/frontend/js/utils.js', - '/frontend/js/i18n.js', - '/frontend/js/offline.js', - '/frontend/js/offline-db.js', - '/frontend/style.css', - '/static/lucide.min.js' + '/static/style.css', + '/static/js/app.js', + '/static/js/state.js', + '/static/js/auth.js', + '/static/js/ui.js', + '/static/js/sidebar.js', + '/static/js/viewer.js', + '/static/js/search.js', + '/static/js/config.js', + '/static/js/utils.js', + '/static/js/i18n.js', + '/static/js/offline.js', + '/static/js/offline-db.js', + '/static/js/sync.js', + '/static/js/legacy.js', ]; -// ── Install: precache static assets ──────────────────────────────────────── +// ── Install: precache the shell, then activate immediately ────────────────── self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - console.log('[SW] Precaching static assets'); - return cache.addAll(PRECACHE_URLS); - }).then(() => self.skipWaiting()) - ); + event.waitUntil((async () => { + const cache = await caches.open(CODE_CACHE); + // Precache individually so a single 404 never aborts the whole install. + await Promise.all(PRECACHE_URLS.map((url) => + cache.add(new Request(url, { cache: 'reload' })).catch(() => {}) + )); + await self.skipWaiting(); + })()); }); -// ── Activate: clean old caches ───────────────────────────────────────────── +// ── Activate: drop every previous cache (old fixed-name caches included) ──── self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE) - .map((name) => caches.delete(name)) - ); - }).then(() => self.clients.claim()) - ); + event.waitUntil((async () => { + const keep = new Set([CODE_CACHE, RUNTIME_CACHE, API_CACHE]); + const names = await caches.keys(); + await Promise.all(names.map((name) => (keep.has(name) ? null : caches.delete(name)))); + await self.clients.claim(); + })()); }); -// ── Fetch: Network-first for API, Cache-first for static ─────────────────── -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // Skip non-GET - if (event.request.method !== 'GET') return; - - // Skip cross-origin - if (url.origin !== location.origin) return; - - // API requests: Network-first with offline fallback - if (url.pathname.startsWith('/api/')) { - event.respondWith(networkFirstStrategy(event.request)); - return; - } - - // Static assets: Cache-first - event.respondWith(cacheFirstStrategy(event.request)); -}); +function isCodeRequest(url, request) { + if (request.mode === 'navigate') return true; + if (url.pathname === '/sw.js' || url.pathname === '/manifest.json') return true; + if (url.pathname.startsWith('/static/js/')) return true; + return /\.(?:js|mjs|css|html|json)$/i.test(url.pathname); +} -// Network-first strategy (for API) -async function networkFirstStrategy(request) { +async function networkFirst(request, cacheName, makeFallback) { try { const response = await fetch(request); - if (response.ok) { - const cache = await caches.open(OFFLINE_CACHE); + if (response && response.ok && response.type === 'basic') { + const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch (error) { const cached = await caches.match(request); if (cached) return cached; - return new Response(JSON.stringify({ error: 'Offline' }), { - status: 503, - headers: { 'Content-Type': 'application/json' } - }); - } -} - -// Cache-first strategy (for static assets) -async function cacheFirstStrategy(request) { - const cached = await caches.match(request); - if (cached) { - // Stale-while-revalidate: update cache in background - fetch(request).then((response) => { - if (response.ok) { - caches.open(CACHE_NAME).then((cache) => cache.put(request, response)); - } - }).catch(() => {}); - return cached; - } - - try { - const response = await fetch(request); - if (response.ok) { - const cache = await caches.open(CACHE_NAME); - cache.put(request, response.clone()); - } - return response; - } catch (error) { - // Return offline page for navigation requests - if (request.mode === 'navigate') { - return caches.match('/index.html'); - } + if (makeFallback) return makeFallback(); return new Response('Offline', { status: 503 }); } } +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const fresh = fetch(request).then((response) => { + if (response && response.ok && response.type === 'basic') { + cache.put(request, response.clone()); + } + return response; + }).catch(() => null); + if (cached) return cached; + return (await fresh) || new Response('Offline', { status: 503 }); +} + +// ── Fetch routing ─────────────────────────────────────────────────────────── +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + // Let the browser handle range requests (PDF/streamed media) directly. + if (request.headers.has('range')) return; + + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirst(request, API_CACHE, () => + new Response(JSON.stringify({ error: 'Offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + )); + return; + } + + if (isCodeRequest(url, request)) { + event.respondWith(networkFirst(request, CODE_CACHE, async () => { + const cached = await caches.match(OFFLINE_URL); + return cached || new Response('

Hors ligne

', { + status: 503, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + })); + return; + } + + event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE)); +}); + +// ── Message from the page ─────────────────────────────────────────────────── +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') self.skipWaiting(); +}); + // ── Push Event: Display notification ─────────────────────────────────────── self.addEventListener('push', (event) => { if (!event.data) return; - + const data = event.data.json(); const options = { body: data.body || data.message || 'Nouvelle notification', @@ -136,7 +156,7 @@ self.addEventListener('push', (event) => { ], requireInteraction: false }; - + event.waitUntil( self.registration.showNotification(data.title || 'ObsiGate', options) ); @@ -145,21 +165,18 @@ self.addEventListener('push', (event) => { // ── Notification Click: Open the app ─────────────────────────────────────── self.addEventListener('notificationclick', (event) => { event.notification.close(); - + if (event.action === 'dismiss') return; - - // Default action or 'open' action: navigate to the file + const urlToOpen = event.notification.data?.url || '/'; - + event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { - // Try to focus existing window for (const client of clientList) { if (client.url.includes(urlToOpen) && 'focus' in client) { return client.focus(); } } - // Open new window if (clients.openWindow) { return clients.openWindow(urlToOpen); } @@ -167,7 +184,6 @@ self.addEventListener('notificationclick', (event) => { ); }); -// ── Notification Close ───────────────────────────────────────────────────── self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event.notification.tag); }); @@ -184,7 +200,7 @@ async function syncFileIndex() { const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200'); if (resp.ok) { const data = await resp.json(); - const files = (data.results || []).map(r => ({ + const files = (data.results || []).map((r) => ({ vault: r.vault, path: r.path, title: r.title, @@ -192,10 +208,9 @@ async function syncFileIndex() { snippet: r.snippet || '', modified: r.modified || '' })); - - // Send to main thread for IndexedDB storage + const clients = await self.clients.matchAll(); - clients.forEach(client => client.postMessage({ + clients.forEach((client) => client.postMessage({ type: 'OFFLINE_INDEX_SYNC', files })); @@ -205,16 +220,8 @@ async function syncFileIndex() { } } -// ── Periodic Background Sync (if supported) ──────────────────────────────── self.addEventListener('periodicsync', (event) => { if (event.tag === 'periodic-index-sync') { event.waitUntil(syncFileIndex()); } }); - -// ── Message from main thread ─────────────────────────────────────────────── -self.addEventListener('message', (event) => { - if (event.data?.type === 'SKIP_WAITING') { - self.skipWaiting(); - } -}); \ No newline at end of file diff --git a/tests/frontend/sw.test.mjs b/tests/frontend/sw.test.mjs new file mode 100644 index 0000000..4bdce08 --- /dev/null +++ b/tests/frontend/sw.test.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * ObsiGate - Service Worker caching policy guards. + * + * Regression tests for the mobile/Cloudflare stale-load bug: the SW used + * cache-first with a fixed cache name and wrong precache paths, so old JS kept + * being served even after flushing the browser/Cloudflare caches (the SW Cache + * Storage is a separate store). These tests read the source and assert the + * network-first policy, versioned caches and corrected paths stay in place. + * + * Usage: node tests/frontend/sw.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const FRONTEND = path.resolve(__dirname, "..", "..", "frontend"); + +const sw = readFileSync(path.join(FRONTEND, "sw.js"), "utf-8"); +const indexHtml = readFileSync(path.join(FRONTEND, "index.html"), "utf-8"); +const sync = readFileSync(path.join(FRONTEND, "js", "sync.js"), "utf-8"); + +let testCount = 0; +let passCount = 0; + +function test(name, fn) { + testCount++; + try { + fn(); + console.log(` \u2713 ${name}`); + passCount++; + } catch (e) { + console.log(` \u2717 ${name}`); + console.log(` ${e.message}`); + } +} + +// ── Service worker ───────────────────────────────────────────────────────── +test("service worker uses a versioned cache name", () => { + assert.ok(/const\s+SW_VERSION\s*=/.test(sw), "SW_VERSION constant present"); + assert.ok(sw.includes("obsigate-code-${SW_VERSION}"), "code cache is versioned"); + assert.ok(sw.includes("obsigate-runtime-${SW_VERSION}"), "runtime cache is versioned"); + assert.ok(sw.includes("obsigate-api-${SW_VERSION}"), "api cache is versioned"); +}); + +test("legacy fixed cache name is gone", () => { + assert.ok(!/obsigate-v1['"]/.test(sw), "must not keep the fixed obsigate-v1 cache name"); +}); + +test("code/navigation requests are network-first", () => { + assert.ok(/function\s+networkFirst\s*\(/.test(sw), "networkFirst strategy present"); + assert.ok(/isCodeRequest\s*\(/.test(sw), "code-request classifier present"); + // The fetch handler must route code requests through networkFirst. + assert.ok(/isCodeRequest\([\s\S]*?networkFirst\(/.test(sw), "code requests use networkFirst"); + assert.ok(/request\.mode\s*===\s*['"]navigate['"]/.test(sw), "navigations are treated as code"); +}); + +test("precache uses real /static paths", () => { + assert.ok(sw.includes("'/static/js/app.js'"), "app.js precached at /static/js"); + assert.ok(sw.includes("'/static/style.css'"), "style.css precached at /static"); + assert.ok(!sw.includes("/frontend/js/"), "no stale /frontend/js path in precache"); +}); + +test("precache failures are non-fatal", () => { + assert.ok(/PRECACHE_URLS\.map[\s\S]*?\.catch\(/.test(sw), "per-URL catch so a 404 does not abort install"); +}); + +test("worker activates immediately and claims clients", () => { + assert.ok(sw.includes("skipWaiting()"), "skipWaiting present"); + assert.ok(sw.includes("clients.claim()"), "clients.claim present"); +}); + +// ── index.html migration ─────────────────────────────────────────────────── +test("index.html uses a one-time keyed migration, not a session kill-switch", () => { + assert.ok(indexHtml.includes("obsigate-sw-migration"), "keyed migration guard present"); + assert.ok(!indexHtml.includes("_sw_killed"), "legacy session kill-switch removed"); + assert.ok(!/getRegistrations\(\)[\s\S]{0,200}unregister/.test(indexHtml), "does not unregister on every session"); +}); + +// ── sync.js registration ─────────────────────────────────────────────────── +test("sync.js reloads once when a new worker takes control", () => { + assert.ok(sync.includes("controllerchange"), "controllerchange listener present"); + assert.ok(sync.includes("SKIP_WAITING"), "asks the waiting worker to activate"); +}); + +console.log(`\n${passCount}/${testCount} service-worker tests passed`); +if (passCount !== testCount) { + process.exit(1); +} diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 8ad6a9d..3f16485 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -719,4 +719,38 @@ class TestSecretRedactor: def test_redact_plain_text(self): from backend.secret_redactor import redact_file_content result = redact_file_content("hello world this is safe") - assert result == "hello world this is safe" \ No newline at end of file + assert result == "hello world this is safe" + + +# ═══════════════════════════════════════════════════════════════════ +# Static / PWA caching (Cloudflare / mobile freshness) +# ═══════════════════════════════════════════════════════════════════ + + +class TestStaticCaching: + """Unhashed assets must revalidate (``no-cache``) to avoid stale builds. + + This is the server-side half of the fix for mobile loads behind + Cloudflare: without ``Cache-Control`` the edge/browser could serve an old + build indefinitely. + """ + + def test_spa_index_revalidates(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert "no-cache" in resp.headers.get("cache-control", "") + + def test_service_worker_revalidates(self, client): + resp = client.get("/sw.js") + assert resp.status_code == 200 + assert "no-cache" in resp.headers.get("cache-control", "") + + def test_manifest_revalidates(self, client): + resp = client.get("/manifest.json") + assert resp.status_code == 200 + assert "no-cache" in resp.headers.get("cache-control", "") + + def test_static_asset_revalidates(self, client): + resp = client.get("/static/style.css") + assert resp.status_code == 200 + assert "no-cache" in resp.headers.get("cache-control", "") \ No newline at end of file