Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 |
@@ -1,5 +1,59 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
|
||||
|
||||
### Changed (la bascule A20 phase 3)
|
||||
|
||||
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
|
||||
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
|
||||
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
|
||||
v8).
|
||||
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
|
||||
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
|
||||
(v7.37).
|
||||
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
|
||||
- Scan statique final sur **tous** les templates : 0 expression incompatible
|
||||
(4 résidus = faux positifs dans des chaînes de texte).
|
||||
|
||||
### Notes
|
||||
|
||||
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
|
||||
CSP réel** (le route-swap du `csp_preview` sert désormais le même
|
||||
fichier — les gates restent utiles contre une régression du build).
|
||||
- board/table_view/teamload/card_detail : scan propre mais non gateés
|
||||
(gitea down) → à vérifier au premier usage avec gitea remonté.
|
||||
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
|
||||
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
|
||||
|
||||
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` → boutons Share/Star/Settings/Login en
|
||||
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
|
||||
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
|
||||
**échappe ses arguments `str`** → tous les segments littéraux sortent
|
||||
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
|
||||
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
|
||||
reproductible partout (`curl /workspaces`).
|
||||
- **Fix racine (5 templates)** : conversion en **block-set**
|
||||
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
|
||||
interpolation = Markup brut — la forme idiomatique Jinja) :
|
||||
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
|
||||
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
|
||||
matcher **une ligne**.
|
||||
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
|
||||
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `"`.
|
||||
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
|
||||
rendent à nouveau).
|
||||
|
||||
### Notes
|
||||
|
||||
- Probable régression depuis A10 (activation d'autoescape) : les `~`
|
||||
étaient des no-op avant, Markup.__radd__ échappait déjà… les
|
||||
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
|
||||
|
||||
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
|
||||
|
||||
### Changed
|
||||
|
||||
+3
-3
@@ -1137,13 +1137,13 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
||||
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
|
||||
- [x] **A20 — CSP sans filet — ✅ TERMINÉ 2026-10-01 (v7.43.0)** : `script-src = 'self' 'nonce-…'` (**plus aucun `unsafe-eval`**) (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
|
||||
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
|
||||
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
|
||||
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
|
||||
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
|
||||
**Reste ph3** : board/table_view/teamload/card_detail (scan propre, gates liés au contexte Gitea — gitea actuellement down) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval).
|
||||
**🐛 BUG pré-existant à analyser (trouvé v7.41.0)** : **`right_actions` du topbar servi ÉCHAPPÉ sur TOUTES les pages** (entities `"`/`<`, boutons Share/Star/Settings en texte brut — repro `curl /workspaces`); `_header:141` a bien `|safe`, l'ENV est standard, rendu local du même motif = PARSED → cause serveur à cerner (piste : valeur échappée à la construction du set). Effort : **M**. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
||||
**BASCULE RÉELLE 2026-10-01 (v7.43.0)** : `alpine.csp.min.js` servi partout (base/import/welcome + sw), `unsafe-eval` retiré de la CSP (assert test inversée), scan statique final = **0** (4 faux positifs dans des chaînes), E2E 7/7 **sous CSP réel** + suite 1094/1094. board/table_view/teamload/card_detail : scan propre, non gateés (gitea down) — à vérifier au premier usage avec gitea remonté.
|
||||
**🐛 BUG TOPBAR — CORRIGÉ 2026-10-01 (v7.42.0)** : cause racine = `'literal' ~ fd_icon(…)` — `fd_icon` = macro → `Markup`, et `Markup.__radd__/__add__` **échappe les segments `str` littéraux** → boutons en entities partout ; `|safe` no-op sur Markup déjà échappé. Fix : **block-set** `{% set right_actions %}…{{ fd_icon() }}…{% endset %}` dans les 5 templates (gitea, page_editor ×2, workspace, workspaces) + **test permanent** `tests/test_topbar_right_actions.py` + assertion `.star-btn` rétablie dans le gate éditeur. Probable régression depuis A10 (autoescape). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
||||
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
|
||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.41.0 (A20 ph3 LOT 3b : gitea + agent + éditeur verts — 12 sites window.E délégués; BUG pré-existant : right_actions servi échappé partout) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.43.0 (**A20 TERMINÉ** : bascule Alpine CSP + unsafe-eval retiré de la CSP) | **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
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.41.0",
|
||||
version="7.43.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -71,17 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
|
||||
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
|
||||
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
|
||||
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
|
||||
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
|
||||
# Alpine.data — voir ROADMAP.
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall(\'toggleComments\')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set page_title = "Workspace — Projects" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.41.0"
|
||||
"version": "7.43.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Où atterrit right_actions sur /pages/{id} ? (parsé ou texte ?)
|
||||
const { chromium } = require('playwright-core');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
function findChromium() {
|
||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
||||
dirs.sort();
|
||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
||||
}
|
||||
const BASE = 'http://localhost:8080';
|
||||
const CSP_JS = path.join(__dirname, 'fixtures', 'alpine.csp.js');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
||||
const ctx = await browser.newContext({ serviceWorkers: 'block' });
|
||||
const page = await ctx.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 110)));
|
||||
await page.goto(`${BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
if (await page.locator('#email').count()) {
|
||||
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
||||
await page.fill('#password', process.env.FD_PASS || 'e2e-secret-123');
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
await page.route('**/static/js/alpine.min.js', (r) =>
|
||||
r.fulfill({ path: CSP_JS, contentType: 'application/javascript' })
|
||||
);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-probe-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
await page.goto(`${BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
const info = await page.evaluate(() => {
|
||||
const star = document.querySelector('.star-btn');
|
||||
const anyText = document.body.innerHTML.includes('edCall(');
|
||||
const parsed = document.querySelector('button.star-btn') ? 'parse' : 'pas-de-bouton';
|
||||
const parent = star ? star.parentElement.className : null;
|
||||
const txt = document.body.innerHTML.indexOf('toggleActivityOpen');
|
||||
const ctx = txt >= 0 ? document.body.innerHTML.slice(Math.max(0, txt - 160), txt + 60) : null;
|
||||
return { parsed: parsed, starParent: parent, edCallInHTML: anyText, ctx: ctx };
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 1));
|
||||
console.log('erreurs:', errs.length ? errs : 'aucune');
|
||||
// cleanup
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -134,10 +134,9 @@ test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page
|
||||
});
|
||||
expect(editor.delegates).toBe('ok');
|
||||
expect(editor.ed).toBe('ok');
|
||||
// (le rendu des boutons right_actions est cassé côté SERVEUR —
|
||||
// entities " sur TOUTES les pages, régression pré-existante
|
||||
// documentée au ROADMAP — donc on n'asserte pas leur présence ;
|
||||
// le filet 0-erreur = les expressions évaluées sont parsables.)
|
||||
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
|
||||
// corrigé : block-set) ET leurs expressions passent le filet CSP
|
||||
await expect(page.locator('.star-btn').first()).toBeAttached();
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
|
||||
Vendored
+23
File diff suppressed because one or more lines are too long
+2
-2
@@ -8,7 +8,7 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v7'; // v7 : A20 (htmx allowEval off, Inter auto-hébergé)
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
@@ -23,7 +23,7 @@ const PRECACHE_URLS = [
|
||||
'/static/css/prism.css',
|
||||
// JS
|
||||
'/static/js/htmx.min.js',
|
||||
'/static/js/alpine.min.js',
|
||||
'/static/js/alpine.csp.min.js',
|
||||
'/static/js/sortable.min.js',
|
||||
'/static/js/app.js?v=2.4.8',
|
||||
'/static/js/offline.js?v=6.0.0',
|
||||
|
||||
@@ -226,8 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
|
||||
assert nm, script_src
|
||||
nonce = nm.group(1)
|
||||
assert "'unsafe-inline'" not in script_src, script_src
|
||||
assert "'unsafe-eval'" in script_src # reste A20 phase 3 (Alpine standard,
|
||||
# build CSP bloqué : 13 exprs non parsables + 24 x-html réactifs → ROADMAP)
|
||||
# A20 TERMINÉ : Alpine en build CSP (alpine.csp.min.js) + htmx allowEval=false
|
||||
assert "'unsafe-eval'" not in script_src
|
||||
assert "script-src-attr 'unsafe-inline'" in csp
|
||||
tags = [
|
||||
mm.group(0)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Les `right_actions` du topbar doivent être servis PARSED (A10).
|
||||
|
||||
Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
|
||||
Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
|
||||
boutons livrés en entities (`"`) sur TOUTES les pages. Fix : block-set
|
||||
`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
|
||||
"""
|
||||
from tests.conftest import login_test_client
|
||||
|
||||
|
||||
def test_right_actions_parsed(client):
|
||||
login_test_client(client)
|
||||
r = client.get("/workspaces")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
parsed = 'class="topbar-btn"' in body
|
||||
escaped = ""topbar-btn"" in body
|
||||
print("PARSE:", parsed, "| ESCAPED:", escaped)
|
||||
print("DBG present:", "DBGCLS" in body)
|
||||
k = body.find("DBGCLS=")
|
||||
print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
|
||||
assert parsed and not escaped, "right_actions servi échappé"
|
||||
Reference in New Issue
Block a user