diff --git a/CHANGELOG.md b/CHANGELOG.md index 973c346..c83a91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - FlowDeck +## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403 + +### Fixed + +- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation + arrive en `redirect:'manual'` → une 302 du serveur se lisait + `opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout + (et toute navigation redirigée) affichait la page hors ligne alors que le + backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en + `redirect:'follow'` et sert une **302 synthétique vers l'URL finale** + (Chromium refuse une response `redirected` servie à une navigation → + `net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête + (l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur + `fetch`). +- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** : + `_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et + `toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` — + les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF + retirée, balayage complet des `fetch` mutants refait). +- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs + (logout sous SW → page login, drop de fichier → 200 + création, avec + nettoyage). + ## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 8be1a4f..0dd6749 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1001,7 +1001,8 @@ Détails livrés : - [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id` - [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code) - [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK -- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/réordonnancement (↑↓) via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot. +- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot. +- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts). ### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28) > **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents). diff --git a/VERSION b/VERSION index 3ed5a78..0527021 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.45.0 +7.45.1 diff --git a/WORKLOAD.md b/WORKLOAD.md index fe529ff..5c36318 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.45.0 (Éditeur visuel d'automations = pipeline steps CRUD + conversion JSON + **fix CSP : 51 expressions multi-instructions → méthodes** (30 fichiers-tpl)) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.45.1 (fix logout « hors ligne » SW = navigation en `redirect:'manual'` → 302 rejouée + 302 synthétique, **fix drag & drop upload/favoris = `X-CSRF-Token` manquant** (oublis A19)) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index 4462014..1af0c8d 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.45.0", + version="7.45.1", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 2109a58..bd94417 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.45.0" + "version": "7.45.1" }, "paths": { "/auth/register": { diff --git a/e2e/regression_logout_dnd.spec.js b/e2e/regression_logout_dnd.spec.js new file mode 100644 index 0000000..cbab029 --- /dev/null +++ b/e2e/regression_logout_dnd.spec.js @@ -0,0 +1,108 @@ +/** + * Régression — 2 bugs rapportés (post v7.45.0) : + * 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation + * redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet). + * 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans + * X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel). + * + * Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté. + */ +const { test, expect } = require('@playwright/test'); + +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + const ok = await page + .waitForURL('**/workspaces', { timeout: 8000 }) + .then(() => true) + .catch(() => false); + if (!ok) { + const resp = await page.request.post(`${FD_BASE}/auth/register`, { + data: { email: USER, password: PASS, name: 'E2E' }, + }); + if (!resp.ok() && resp.status() !== 409) { + throw new Error(`register ${resp.status()}: ${await resp.text()}`); + } + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForURL('**/workspaces', { timeout: 15000 }); + } + // workspace actif requis par /api/local-workspace/upload (A22) + const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json()); + if (!ws.workspaces || ws.workspaces.length === 0) { + await page.evaluate(async () => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/api/workspaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ name: 'E2E workspace' }), + }); + const w = await r.json(); + await fetch(`/api/workspaces/${w.id}/select`, { + method: 'POST', + headers: { 'X-CSRF-Token': csrf }, + }); + }); + } +} + +test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => { + await login(page); + await page.evaluate(async () => { + if ('serviceWorker' in navigator) await navigator.serviceWorker.ready; + }); + await page.waitForTimeout(1000); + expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true); + + await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' }); + await page.waitForURL(/\/auth\/login/, { timeout: 10000 }); + const body = await page.evaluate(() => document.body.innerText); + expect(body).not.toContain('hors ligne'); + expect(page.url()).toContain('/auth/login'); +}); + +test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => { + test.setTimeout(60000); + await login(page); + await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1000); + + const uploadResp = []; + page.on('response', (r) => { + if (r.url().includes('/api/local-workspace/upload')) { + uploadResp.push( + r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' })) + ); + } + }); + + await page.evaluate(() => { + const zone = document.querySelector('[x-data*="wsInitData"]') || document.body; + const dt = new DataTransfer(); + dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' })); + zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true })); + zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })); + }); + + await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0); + const resp = await uploadResp[0]; + expect(resp.status, resp.body).toBe(200); + + // Nettoyage : l'upload crée une page fichier → on la supprime. + const parsed = JSON.parse(resp.body || '{}'); + for (const item of parsed.items || []) { + if (!item.id) continue; + await page.evaluate(async (pid) => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + await fetch('/api/local-workspace/items/' + pid, { + method: 'DELETE', + headers: { 'X-CSRF-Token': csrf }, + }); + }, item.id); + } +}); diff --git a/static/js/local_workspace.js b/static/js/local_workspace.js index d8f901b..88291a9 100644 --- a/static/js/local_workspace.js +++ b/static/js/local_workspace.js @@ -628,7 +628,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis → if (!node) return; try { var method = node.favorited ? 'DELETE' : 'POST'; - var r = await fetch('/board/api/favorites/' + node.id, { method: method }); + var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} }); if (r.ok) { node.favorited = !node.favorited; if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites(); @@ -1763,7 +1763,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis → var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload'; try { - var r = await fetch(url, { method: 'POST', body: formData }); + var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData }); this.uploadProgress = 80; var d = await r.json(); if (r.ok) { diff --git a/static/sw.js b/static/sw.js index 354d69e..c4497f2 100644 --- a/static/sw.js +++ b/static/sw.js @@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) { async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) { const cache = await caches.open(cacheName); try { - const res = await timeoutFetch(request, timeoutMs); + let res = await timeoutFetch(request, timeoutMs); + // Les navigations interceptées arrivent en redirect:'manual' : une 302 du + // serveur se lit opaqueredirect (status 0) → on rejoue la requête avec suivi + // explicite. Sans ça, tout logout / redirection rendait la page hors ligne. + if (res.type === 'opaqueredirect') { + // Navigation en redirect:'manual' : on suit la redirection à la main. + res = await timeoutFetch(new Request(request, { redirect: 'follow' }), timeoutMs); + if (res && res.ok && res.redirected) { + // Chromium refuse une response 'redirected' servie à une navigation + // (ERR_FAILED) → on émet une vraie redirection vers l'URL finale. + return new Response(null, { status: 302, headers: { Location: res.url } }); + } + } if (res && res.ok) { - cache.put(request, res.clone()); + // Ne pas mettre en cache une réponse de redirection sous l'URL d'origine. + if (!res.redirected) cache.put(request, res.clone()); return res; } throw new Error('bad status'); @@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = function timeoutFetch(request, ms) { return new Promise((resolve, reject) => { + const ctrl = new AbortController(); const timer = setTimeout(() => { - const controller = new AbortController(); - controller.abort(); + ctrl.abort(); reject(new Error('timeout')); }, ms); - fetch(request).then( + fetch(request, { signal: ctrl.signal }).then( (res) => { clearTimeout(timer); resolve(res); }, (err) => { clearTimeout(timer); reject(err); } );