Compare commits

..
3 Commits
Author SHA1 Message Date
bruno 07904f05e5 fix: A21 phase 2a — api_v2 : body JSON en paramètre, 36 routes hors loop (v7.9.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Les 36 routes d'api_v2 dont le SEUL `await` était
  `body = await request.json()` (bloc try/except uniforme) → paramètre FastAPI
  `body: dict = Body(default={})` + conversion en `def` → threadpool :
  toute leur séquence SQLite quitte l'event loop.
- Équivalences vérifiées avant engament (probe FastAPI) :
  · corps absent → `{}` (identique à l'ancien try/except)
  · JSON invalide → 422 (avant : avalé comme `{}` — 422 est plus juste)
  · zéro `body[...]=` / setdefault / update dans api_v2 → défaut partagé
    jamais muté
- verify_webhook_signature (signature multi-ligne) traitée à la main.
- Piège courant évité : première version du script supprimait 5 lignes au
  lieu de 4 (slice m-1:m+4) → fichier restauré depuis git, slice corrigée,
  0 ligne perdue (diff logique +39/-183).

api_v2 : 96/115 routes hors loop (60 phase 1 + 36 ici) ; 19 async restantes
(fire_event, request.form, gitea/webhooks) = phase 2b.

suite **1037/1037** (242 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:19:58 -04:00
bruno 224bda74d5 fix: A21 phase 1 — 352 routes async sans await → threadpool (v7.8.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
  ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
  corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
  `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
  dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
  l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
  workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
  api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
  (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
  DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
  (rien ne l'appellerait — YAGNI jusqu'au premier usage).

suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
2026-10-01 10:53:26 -04:00
bruno c718fe06de fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
  ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
  `script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
  sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
  scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
  helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
  (chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
  : htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
  restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
  : vues chart/map déjà BLOQUÉES par la CSP depuis toujours
  (commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
  JS (A27), resserrer img-src/connect-src

test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)

suite **1037/1037** · `ruff check app tests` OK · docs à jour
2026-10-01 10:41:39 -04:00
80 changed files with 1008 additions and 588 deletions
+73
View File
@@ -1,5 +1,78 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
### Changed
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
était `body = await request.json()` passent à un paramètre FastAPI
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
il ne reste que **19 routes async** dans ce router (`fire_event`,
`request.form`, appels gitea/webhooks — phase 2b)
- Bug de transformation évité en cours de route : première version du script
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
### Tests
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
`test_v65` + audit : 62/62 verts avant la passe complète
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
### Changed
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
l'instant (rien ne l'appellerait)
### Perf
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
handlers SQLite ne saturent plus la boucle pendant les tests
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
### Security
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
- Les 74 handlers `onclick=` inline restent fonctionnels via
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
aurait désactivés aussi)
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
`style-src` : les vues chart/map de `collections` les utilisent et étaient
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
vendoriser ces libs puis retirer les hôtes)
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
`img-src`/`connect-src`
### Tests
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
header identique sur tous les scripts inline, nonce différent d'une requête à
l'autre) et page hors template (`/auth/login?provider=local`)
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations) ## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed ### Fixed
+3 -3
View File
@@ -1137,8 +1137,8 @@ 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] **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] **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] **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**.*
- [ ] **A20 — CSP sans filet : `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'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.* - [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). **Reste** : `unsafe-eval` (Alpine x-data en string → build `@alpinejs/csp`), externalisation JS (A27), resserrer `img-src`/`connect-src`, vendoriser les 2 CDN. Effort : **L**.
- [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 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`. Effort : **M**.* - [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**. **Reste (phase 2b)** : les **311-36 = 275 routes** avec de vrais `await` (`fire_event`, `request.form`, httpx…) → enrouler les blocs DB dans `await anyio.to_thread.run_sync(lambda: ...)` (pas de wrapper pour l'instant : rien ne l'appellerait encore) — dans api_v2 il n'en reste que **19**. Effort : **M→L**.
- [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] **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**.* - [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**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.* - [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
@@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0.
+1 -1
View File
@@ -1 +1 @@
7.6.0 7.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.6.0 (audit — A31) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.9.0 (audit — A21 phase 2a : api_v2 à 96/115 hors loop) | **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` > **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 ## Avancement Global
+7 -7
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.6.0", version="7.9.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json") @app.get("/manifest.json")
async def pwa_manifest(): def pwa_manifest():
"""Serve the static web manifest from disk (same URL as before v6.0.0).""" """Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json") return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js") @app.get("/sw.js")
async def service_worker(): def service_worker():
"""Serve the PWA service worker at top-level scope (/).""" """Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript") return FileResponse("static/sw.js", media_type="application/javascript")
@@ -282,7 +282,7 @@ async def service_worker():
@app.get("/api/csrf-token") @app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request): def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403.""" """Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
import secrets import secrets
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
@app.get("/api/pages") @app.get("/api/pages")
async def api_pages_alias(request: Request): def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency.""" """Alias /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
qs = str(request.url.query) qs = str(request.url.query)
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
@app.post("/api/pages") @app.post("/api/pages")
async def api_pages_post_alias(request: Request): def api_pages_post_alias(request: Request):
"""Alias POST /api/pages → /board/api/pages for API path consistency.""" """Alias POST /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/board/api/pages", status_code=307) return RedirectResponse(url="/board/api/pages", status_code=307)
@@ -341,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(_StarHTTPException) @app.exception_handler(_StarHTTPException)
async def http_exception_handler(request: Request, exc: _StarHTTPException): def http_exception_handler(request: Request, exc: _StarHTTPException):
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML. """Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
Registered on Starlette's HTTPException (the base class) so it catches both Registered on Starlette's HTTPException (the base class) so it catches both
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import secrets
import time import time
from collections import defaultdict from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ──────────────────────────────────────────────── # ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads # Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
""" """
CSP_HEADER = "Content-Security-Policy" CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# 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` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = ( CSP_VALUE = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " # chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; " "font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; " "connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
) )
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request) response = await call_next(request)
# Only set CSP on HTML responses # Only set CSP on HTML responses
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
if "text/html" in content_type: if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response return response
+4 -4
View File
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
# ── Users ── # ── Users ──
@router.get("/users") @router.get("/users")
async def list_users(_admin=Depends(admin_required)): def list_users(_admin=Depends(admin_required)):
"""List all users with workspace/file/folder counts and storage usage.""" """List all users with workspace/file/folder counts and storage usage."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
@router.delete("/users/{user_id:int}") @router.delete("/users/{user_id:int}")
async def delete_user(user_id: int, _admin=Depends(admin_required)): def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data.""" """Delete a user and cascade their data."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
# ── Stats ── # ── Stats ──
@router.get("/stats") @router.get("/stats")
async def user_stats(_admin=Depends(admin_required)): def user_stats(_admin=Depends(admin_required)):
"""Aggregate stats: total users, workspaces, files, storage.""" """Aggregate stats: total users, workspaces, files, storage."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
# ── Audit ── # ── Audit ──
@router.get("/audit") @router.get("/audit")
async def audit_log(limit: int = 100, _admin=Depends(admin_required)): def audit_log(limit: int = 100, _admin=Depends(admin_required)):
"""Recent login history.""" """Recent login history."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
+10 -10
View File
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
@router.get("/conversations/{conversation_id}") @router.get("/conversations/{conversation_id}")
async def get_conversation(request: Request, conversation_id: int): def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone() conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv: if not conv:
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
@router.delete("/conversations/{conversation_id}") @router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: int): def delete_conversation(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone(): if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
raise HTTPException(status_code=404, detail="Conversation introuvable") raise HTTPException(status_code=404, detail="Conversation introuvable")
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
@router.get("/conversations/{conversation_id}/actions") @router.get("/conversations/{conversation_id}/actions")
async def list_actions(request: Request, conversation_id: int): def list_actions(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id", "SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
@router.post("/actions/{action_id}/undo") @router.post("/actions/{action_id}/undo")
async def undo(request: Request, action_id: int): def undo(request: Request, action_id: int):
try: try:
undo_action(action_id) undo_action(action_id)
except ValueError as exc: except ValueError as exc:
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
@router.get("/skills/gallery") @router.get("/skills/gallery")
async def skills_gallery(request: Request): def skills_gallery(request: Request):
presets = skill_gallery.list_gallery() presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets), return {"gallery": presets, "total": len(presets),
"install": "POST /api/agent/skills/gallery/{slug}/install"} "install": "POST /api/agent/skills/gallery/{slug}/install"}
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
@router.get("/skills/{skill_id}/export") @router.get("/skills/{skill_id}/export")
async def export_skill(request: Request, skill_id: int): def export_skill(request: Request, skill_id: int):
"""Document JSON portable — à rejouer sur /api/agent/skills/import.""" """Document JSON portable — à rejouer sur /api/agent/skills/import."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
@router.delete("/skills/{skill_id}") @router.delete("/skills/{skill_id}")
async def delete_skill(request: Request, skill_id: int): def delete_skill(request: Request, skill_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row: if not row:
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
@router.get("/tools") @router.get("/tools")
async def list_tools(request: Request): def list_tools(request: Request):
registry = ToolRegistry() registry = ToolRegistry()
tools = registry.schema() tools = registry.schema()
return {"tools": tools} return {"tools": tools}
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
@router.get("/{agent_id}") @router.get("/{agent_id}")
async def get_agent(request: Request, agent_id: int): def get_agent(request: Request, agent_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row: if not row:
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
@router.delete("/{agent_id}") @router.delete("/{agent_id}")
async def delete_agent(request: Request, agent_id: int): def delete_agent(request: Request, agent_id: int):
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing: if not existing:
+14 -14
View File
@@ -83,7 +83,7 @@ async def health(request: Request):
@router.get("/stats") @router.get("/stats")
async def stats(): def stats():
"""Global stats for dashboard.""" """Global stats for dashboard."""
with get_conn() as conn: with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"] board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
@router.post("/col-mapping") @router.post("/col-mapping")
async def set_col_mapping( def set_col_mapping(
owner: str = Query(...), owner: str = Query(...),
repo: str = Query(...), repo: str = Query(...),
column: str = Query(...), column: str = Query(...),
@@ -209,7 +209,7 @@ async def set_col_mapping(
@router.delete("/col-mapping") @router.delete("/col-mapping")
async def delete_col_mapping( def delete_col_mapping(
owner: str = Query(...), owner: str = Query(...),
repo: str = Query(...), repo: str = Query(...),
column: str = Query(...), column: str = Query(...),
@@ -234,7 +234,7 @@ async def delete_col_mapping(
@router.get("/board-config/{owner}/{repo}") @router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str): def get_board_config(owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
board = conn.execute( board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?", "SELECT * FROM boards WHERE project_owner=? AND project_name=?",
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
@router.post("/board-config/{owner}/{repo}") @router.post("/board-config/{owner}/{repo}")
async def update_board_config( def update_board_config(
owner: str, owner: str,
repo: str, repo: str,
columns: str = Query(default=""), columns: str = Query(default=""),
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
# ── v0.5.0: Checklists ── # ── v0.5.0: Checklists ──
@router.post("/checklists/{owner}/{repo}/{issue_id}") @router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist( def create_checklist(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -484,7 +484,7 @@ async def create_checklist(
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}") @router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item( def add_checklist_item(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -502,7 +502,7 @@ async def add_checklist_item(
@router.patch("/checklist-items/{item_id}") @router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item( def toggle_checklist_item(
item_id: int, item_id: int,
checked: bool = Query(default=False), checked: bool = Query(default=False),
content: str = Query(default=""), content: str = Query(default=""),
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
@router.delete("/checklist-items/{item_id}") @router.delete("/checklist-items/{item_id}")
async def delete_checklist_item(item_id: int): def delete_checklist_item(item_id: int):
"""Delete a checklist item.""" """Delete a checklist item."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,)) conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
@router.delete("/checklists/{checklist_id}") @router.delete("/checklists/{checklist_id}")
async def delete_checklist(checklist_id: int): def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items.""" """Delete a checklist and all its items."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,)) conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
@@ -545,7 +545,7 @@ async def delete_checklist(checklist_id: int):
# ── v1.0.0: User management ── # ── v1.0.0: User management ──
@router.get("/users/me") @router.get("/users/me")
async def get_my_profile(request: Request): def get_my_profile(request: Request):
"""Get current user profile.""" """Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
@router.put("/users/me") @router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""), def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")): email: str = Query(default="")):
"""Update current user's local profile.""" """Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -584,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
# ── v0.5.0: Card priority & due date ── # ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}") @router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card( def update_card(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
@router.get("/frontend-errors") @router.get("/frontend-errors")
async def get_frontend_errors(request: Request, clear: bool = True): def get_frontend_errors(request: Request, clear: bool = True):
"""Retourne les erreurs frontend récentes. Hermes appelle cette route.""" """Retourne les erreurs frontend récentes. Hermes appelle cette route."""
errors = list(_frontend_errors) errors = list(_frontend_errors)
if clear: if clear:
+99 -243
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
# ── Agents ───────────────────────────────────────────────────────────────── # ── Agents ─────────────────────────────────────────────────────────────────
@router.get("/agents") @router.get("/agents")
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)): def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
ws = _workspace_of(request) ws = _workspace_of(request)
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
@router.get("/agents/{agent_id}") @router.get("/agents/{agent_id}")
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
@router.delete("/agents/{agent_id}") @router.delete("/agents/{agent_id}")
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone(): if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
# ── Conversations (static paths declared before /agents/{agent_id}) ──────── # ── Conversations (static paths declared before /agents/{agent_id}) ────────
@router.get("/agents/conversations") @router.get("/agents/conversations")
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)): def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
@router.get("/agents/conversations/{conversation_id}") @router.get("/agents/conversations/{conversation_id}")
async def get_conversation_v2(conversation_id: int, request: Request, def get_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
@router.delete("/agents/conversations/{conversation_id}") @router.delete("/agents/conversations/{conversation_id}")
async def delete_conversation_v2(conversation_id: int, request: Request, def delete_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
@router.get("/agents/conversations/{conversation_id}/actions") @router.get("/agents/conversations/{conversation_id}/actions")
async def list_actions_v2(conversation_id: int, request: Request, def list_actions_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
@router.post("/agents/actions/{action_id}/undo") @router.post("/agents/actions/{action_id}/undo")
async def undo_action_v2(action_id: int, request: Request, def undo_action_v2(action_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
# ── Skill marketplace ────────────────────────────────────────────────────── # ── Skill marketplace ──────────────────────────────────────────────────────
@router.get("/skills") @router.get("/skills")
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)): def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
ws = _workspace_of(request) ws = _workspace_of(request)
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
# Gallery & import are static segments: declared before /skills/{skill_id} so # Gallery & import are static segments: declared before /skills/{skill_id} so
# FastAPI never tries to coerce "gallery" into an int path parameter. # FastAPI never tries to coerce "gallery" into an int path parameter.
@router.get("/skills/gallery") @router.get("/skills/gallery")
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)): def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
presets = skill_gallery.list_gallery() presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets), return {"gallery": presets, "total": len(presets),
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
@router.get("/skills/{skill_id}") @router.get("/skills/{skill_id}")
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)): def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
@router.get("/skills/{skill_id}/export") @router.get("/skills/{skill_id}/export")
async def export_skill_v2(skill_id: int, request: Request, def export_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere.""" """Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
_guard(request, authorization) _guard(request, authorization)
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
@router.delete("/skills/{skill_id}") @router.delete("/skills/{skill_id}")
async def delete_skill_v2(skill_id: int, request: Request, def delete_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
+1 -1
View File
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
@router.get("/api/v2/audit/logs") @router.get("/api/v2/audit/logs")
async def audit_logs(request: Request): def audit_logs(request: Request):
_admin_user(request) _admin_user(request)
qp = request.query_params qp = request.query_params
source = (qp.get("source") or "all").lower() source = (qp.get("source") or "all").lower()
+19 -9
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.config import settings from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth") router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback" return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html> LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register") @router.get("/register")
async def register_page(request: Request): def register_page(request: Request):
"""Show the registration page (local login page with register tab active).""" """Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace( return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"', 'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"' 'class="tab" onclick="switchTab(\'login\')"'
).replace( ).replace(
@@ -163,16 +173,16 @@ async def register_page(request: Request):
).replace( ).replace(
'id="submit-btn">Login<', 'id="submit-btn">Login<',
'id="submit-btn">Register<' 'id="submit-btn">Register<'
), status_code=200) )), status_code=200)
@router.get("/login") @router.get("/login")
async def login(request: Request, provider: str = Query("gitea")): def login(request: Request, provider: str = Query("gitea")):
"""Redirect to OAuth2 authorize page or show local login page.""" """Redirect to OAuth2 authorize page or show local login page."""
# Local login page (POST handled by /auth/local-login) # Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
if provider == "local": if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200) return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured # OAuth flow — check if provider is configured
from app.auth.providers import get_provider from app.auth.providers import get_provider
@@ -450,7 +460,7 @@ async def callback(
@router.get("/logout") @router.get("/logout")
async def logout(request: Request): def logout(request: Request):
"""Clear session and redirect to login page. """Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one SAML sessions additionally hand over to the IdP's Single Logout when one
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
@router.get("/2fa/status") @router.get("/2fa/status")
async def twofa_status(request: Request): def twofa_status(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
return {"enabled": _2fa.is_enabled(user["id"]), return {"enabled": _2fa.is_enabled(user["id"]),
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
@router.post("/2fa/setup") @router.post("/2fa/setup")
async def twofa_setup(request: Request): def twofa_setup(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
return _2fa.setup_secret(user["id"]) return _2fa.setup_secret(user["id"])
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
@router.post("/2fa/disable") @router.post("/2fa/disable")
async def twofa_disable(request: Request): def twofa_disable(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
_2fa.disable(user["id"]) _2fa.disable(user["id"])
+6 -6
View File
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
@router.get("/workspace/automations") @router.get("/workspace/automations")
async def list_automations(request: Request): def list_automations(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall() rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows] items = [dict(r) for r in rows]
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
@router.get("/workspace/automations/{auto_id}") @router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int): def get_automation(request: Request, auto_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone() row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row: if not row:
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
@router.delete("/workspace/automations/{auto_id}") @router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int): def delete_automation(request: Request, auto_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,)) conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit() conn.commit()
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
@router.get("/workspace/automations/{auto_id}/runs") @router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50): def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=? """SELECT * FROM automation_runs WHERE automation_id=?
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
@router.get("/workspace/automations/{auto_id}/steps") @router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int): def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None: if _get_auto(auto_id) is None:
return _auto_404() return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)} return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
@router.delete("/workspace/automations/steps/{step_id}") @router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int): def delete_step(request: Request, step_id: int):
_require_session(request) _require_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,)) conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
+23 -23
View File
@@ -66,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
@router.get("/api/wiki/pages") @router.get("/api/wiki/pages")
async def wiki_page_search(request: Request, q: str = Query(default="")): def wiki_page_search(request: Request, q: str = Query(default="")):
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
every non-deleted page the current user can see (single source: pages).""" every non-deleted page the current user can see (single source: pages)."""
q = (q or "").strip().lower() q = (q or "").strip().lower()
@@ -98,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
@router.get("/api/wiki/titles") @router.get("/api/wiki/titles")
async def wiki_titles(request: Request, ids: str = Query(default="")): def wiki_titles(request: Request, ids: str = Query(default="")):
"""v5.11.0: resolve page-id lists to current labels (rename propagation).""" """v5.11.0: resolve page-id lists to current labels (rename propagation)."""
parsed: list[int] = [] parsed: list[int] = []
for part in (ids or "").split(","): for part in (ids or "").split(","):
@@ -179,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
@router.get("/api/page-templates") @router.get("/api/page-templates")
async def list_page_templates_api(request: Request): def list_page_templates_api(request: Request):
"""v5.12.0: built-in + user global page templates for the picker.""" """v5.12.0: built-in + user global page templates for the picker."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id") uid = (user or {}).get("id")
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
# ═══════════ Library page ═══════════ # ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.""" """Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
# ═══════════ Favorites API ═══════════ # ═══════════ Favorites API ═══════════
@router.get("/api/favorites") @router.get("/api/favorites")
async def list_favorites(request: Request): def list_favorites(request: Request):
"""List favorited page IDs for the current user.""" """List favorited page IDs for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1 uid = user["id"] if user and user.get("id") else 1
@@ -1033,7 +1033,7 @@ async def unpublish_page(request: Request, page_id: int):
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash") @router.get("/api/trash")
async def list_trash(request: Request): def list_trash(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
@@ -1052,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}") @router.delete("/api/trash/{page_id}")
async def permanent_delete(request: Request, page_id: int): def permanent_delete(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request): def trash_page(request: Request):
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
template = env.get_template("trash.html") template = env.get_template("trash.html")
@@ -1072,7 +1072,7 @@ async def trash_page(request: Request):
# These routes MUST be registered before the catch-all /{owner}/{repo} below. # These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks") @router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")): def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace.""" """List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks from app.services.synced_blocks import list_synced_blocks
@@ -1147,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}") @router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int): def get_synced_block_api(sid: int):
"""Get a synced block by id.""" """Get a synced block by id."""
from app.services.synced_blocks import get_synced_block from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid) sb = get_synced_block(sid)
@@ -1173,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/synced/{sid}") @router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int): def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync).""" """Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid) remove_page_synced(page_id, sid)
@@ -1181,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
@router.get("/api/pages/{page_id}/synced") @router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int): def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page.""" """Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)} return {"synced_blocks": get_page_synced(page_id)}
@@ -1190,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
# ═══════════ Board page ═══════════ # ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")): def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
sidebar = _sidebar_data(request, owner, repo) sidebar = _sidebar_data(request, owner, repo)
@@ -1279,12 +1279,12 @@ async def board_view(
# ═══════════ v0.9.0: Custom Properties API ═══════════ # ═══════════ v0.9.0: Custom Properties API ═══════════
@router.get("/api/properties/{owner}/{repo}") @router.get("/api/properties/{owner}/{repo}")
async def get_properties(owner: str, repo: str): def get_properties(owner: str, repo: str):
return {"properties": _get_project_properties(owner, repo)} return {"properties": _get_project_properties(owner, repo)}
@router.post("/api/properties/{owner}/{repo}") @router.post("/api/properties/{owner}/{repo}")
async def create_property(owner: str, repo: str, name: str = Query(...), def create_property(owner: str, repo: str, name: str = Query(...),
prop_type: str = Query(default="select"), prop_type: str = Query(default="select"),
options: str = Query(default="")): options: str = Query(default="")):
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()]) opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
@@ -1301,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
@router.delete("/api/properties/{owner}/{repo}") @router.delete("/api/properties/{owner}/{repo}")
async def delete_property(owner: str, repo: str, name: str = Query(...)): def delete_property(owner: str, repo: str, name: str = Query(...)):
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", "DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
@@ -1312,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
@router.post("/api/properties/{owner}/{repo}/values") @router.post("/api/properties/{owner}/{repo}/values")
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...), def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
name: str = Query(...), value: str = Query(default="")): name: str = Query(...), value: str = Query(default="")):
with get_conn() as conn: with get_conn() as conn:
prop = conn.execute( prop = conn.execute(
@@ -1332,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
# ═══════════ v0.9.0: AI Keywords API ═══════════ # ═══════════ v0.9.0: AI Keywords API ═══════════
@router.get("/api/ai-keywords/{owner}/{repo}") @router.get("/api/ai-keywords/{owner}/{repo}")
async def get_ai_keywords(owner: str, repo: str): def get_ai_keywords(owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30", "SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
@@ -1395,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
@router.get("/api/pages/{page_id}") @router.get("/api/pages/{page_id}")
async def get_page(request: Request, page_id: int): def get_page(request: Request, page_id: int):
"""Get a Markdown page.""" """Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"): if not user or not user.get("id"):
@@ -1543,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
@router.get("/api/pages/{page_id}/backlinks") @router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int): def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…"). """v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal Scans every non-deleted page's blocks (and raw markdown) for an internal
@@ -1591,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/versions") @router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int): def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page.""" """v5.4.0: version history for a block-editor page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/cover") @router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int): def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,)) conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit() conn.commit()
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
@router.get("/pages/{page_id}", response_class=HTMLResponse) @router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int): def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files. """Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
+2 -2
View File
@@ -49,7 +49,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page.""" """List page-level and inline comments for a FlowDeck page."""
_current_user(request) _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
@router.delete("/comments/{comment_id}") @router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int): def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies.""" """Delete a comment and its replies."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
+29 -28
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule, validate_rule,
) )
from app.services.reminders import REMINDER_KEY, parse_lead from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict: def _current_user(request: Request) -> dict:
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def list_collections(request: Request): def list_collections(request: Request):
"""Page listing all collections in the workspace.""" """Page listing all collections in the workspace."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
@router.get("/api") @router.get("/api")
async def list_collections_api(request: Request): def list_collections_api(request: Request):
"""API: list all collections.""" """API: list all collections."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
@router.get("/pages/{page_id}/api") @router.get("/pages/{page_id}/api")
async def get_page_api(request: Request, page_id: int): def get_page_api(request: Request, page_id: int):
"""API: get a single page.""" """API: get a single page."""
with get_conn() as conn: with get_conn() as conn:
page = conn.execute( page = conn.execute(
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
@router.get("/pages/{page_id}/open/api") @router.get("/pages/{page_id}/open/api")
async def open_row_page_api(request: Request, page_id: int): def open_row_page_api(request: Request, page_id: int):
"""v6.5.0 — content page of a database row (lazy-created). """v6.5.0 — content page of a database row (lazy-created).
Any DB view (table/board/gallery/list/calendar) opens a row through Any DB view (table/board/gallery/list/calendar) opens a row through
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
@router.get("/boards/api") @router.get("/boards/api")
async def list_boards_as_collections(request: Request): def list_boards_as_collections(request: Request):
"""API: list all Gitea boards as pseudo-collections.""" """API: list all Gitea boards as pseudo-collections."""
from app.services.collection_adapter import GiteaBoardCompat from app.services.collection_adapter import GiteaBoardCompat
boards = GiteaBoardCompat.list_boards_as_collections() boards = GiteaBoardCompat.list_boards_as_collections()
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
@router.get("/property-types/api") @router.get("/property-types/api")
async def list_property_types_api(request: Request): def list_property_types_api(request: Request):
"""API: list all available property types.""" """API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES} return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api") @router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int): def list_properties_api(request: Request, collection_id: int):
"""API: list all properties visible to the current user.""" """API: list all properties visible to the current user."""
user = _session_user(request) user = _session_user(request)
_require_view(collection_id, user) _require_view(collection_id, user)
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
@router.get("/{collection_id}/members/api") @router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int): def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property. """API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to Resolves the collection's workspace and returns its members (falling back to
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
@router.get("/{collection_id}/calendar/api") @router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int, def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "", start: str = "", end: str = "",
date_property: str = ""): date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end]. """API (v5.8.0): expanded calendar events for a window [start, end].
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
@router.get("/timezones/api") @router.get("/timezones/api")
async def timezones_api(request: Request): def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list.""" """API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
@router.delete("/properties/{prop_id}/api") @router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int): def delete_property_api(request: Request, prop_id: int):
"""API: delete a property.""" """API: delete a property."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
@router.get("/views/{view_id}/api") @router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int): def get_view_api(request: Request, view_id: int):
"""API: get a single view config.""" """API: get a single view config."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
@router.get("/{collection_id}/views/api") @router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int): def list_views_api(request: Request, collection_id: int):
"""API: list views for a collection visible to the current user. """API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone; Shared/legacy views (``created_by IS NULL``) are visible to everyone;
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
@router.delete("/views/{view_id}/api") @router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int): def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view.""" """API: delete a saved view."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
@router.get("/{collection_id}/pages/{page_id}/sub-items") @router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int): def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page.""" """API: list sub-items of a page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/pages/{page_id}/status-aggregate") @router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int): def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children.""" """API: compute aggregate status from children."""
with get_conn() as conn: with get_conn() as conn:
children = conn.execute( children = conn.execute(
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
@router.get("/{collection_id}/sources/api") @router.get("/{collection_id}/sources/api")
async def list_data_sources(request: Request, collection_id: int): def list_data_sources(request: Request, collection_id: int):
"""API: list all data sources for a collection.""" """API: list all data sources for a collection."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
@router.delete("/{collection_id}/sources/{source_id}/api") @router.delete("/{collection_id}/sources/{source_id}/api")
async def remove_data_source(request: Request, collection_id: int, source_id: int): def remove_data_source(request: Request, collection_id: int, source_id: int):
"""API: remove a data source from a collection.""" """API: remove a data source from a collection."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
@router.post("/{collection_id}/toggle-inline/api") @router.post("/{collection_id}/toggle-inline/api")
async def toggle_inline(request: Request, collection_id: int): def toggle_inline(request: Request, collection_id: int):
"""API: toggle a collection between full-page and inline mode.""" """API: toggle a collection between full-page and inline mode."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute( coll = conn.execute(
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
@router.put("/{collection_id}/toggle-task/api") @router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int): def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks).""" """API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
@router.get("/{collection_id}/pages/{page_id}/dependencies/api") @router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int): def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related).""" """API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1819,7 +1820,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api") @router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int): def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency.""" """API: remove a dependency."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse) @router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int): def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
"""v7.3.0: render a collection dashboard grid (multi-DB widgets). """v7.3.0: render a collection dashboard grid (multi-DB widgets).
Widgets live in ``collection_dashboards.layout_json`` as Widgets live in ``collection_dashboards.layout_json`` as
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
@router.get("/{collection_id}", response_class=HTMLResponse) @router.get("/{collection_id}", response_class=HTMLResponse)
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse) @router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"): def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type.""" """Main view — renders collection in the requested view type."""
# v6.0.0: granular collection permissions — hide restricted collections. # v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request)) _require_view(collection_id, _session_user(request))
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style> </style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div> <div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script> <script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{ new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}', type: '{chart_type}',
data: {{ data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form> </form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div> <div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div> </div>
<script> <script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{ async function submitForm(e) {{
e.preventDefault(); e.preventDefault();
const form = document.getElementById('collectionForm'); const form = document.getElementById('collectionForm');
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script> <script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6); const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map); L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json}; const markers = {markers_json};
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api") @router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int): def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages.""" """API: get a single collection with its pages."""
# v6.0.0: granular collection permissions — hide restricted collections. # v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request)) _require_view(collection_id, _session_user(request))
+40 -40
View File
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided.""" """Trash page — scoped to workspace if owner/repo provided."""
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV from app.templating import ENV
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace). """Library page — tabbed view (recents, favorites, shared, published, private, workspace).
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse) @router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int): def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer. """Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
@@ -434,7 +434,7 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse) @router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request): def accounts_page(request: Request):
"""Account management panel.""" """Account management panel."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -451,7 +451,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse) @router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request): def help_page(request: Request):
"""Comprehensive help & documentation page.""" """Comprehensive help & documentation page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -656,7 +656,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse) @router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request): def settings_page(request: Request):
"""User settings page — profile, forges, tokens.""" """User settings page — profile, forges, tokens."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -731,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token") @router.post("/api/user/token")
async def generate_token(request: Request): def generate_token(request: Request):
import secrets import secrets
uid = _require_user_id(request) uid = _require_user_id(request)
token = secrets.token_hex(32) token = secrets.token_hex(32)
@@ -745,7 +745,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}") @router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str): def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request) uid = _require_user_id(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -834,7 +834,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════ # ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse) @router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request): def workspace_page(request: Request):
"""Unified workspace showing all projects.""" """Unified workspace showing all projects."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -851,7 +851,7 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse) @router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request): def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files.""" """Gitea workspace — browse repo files."""
import json import json
@@ -971,7 +971,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════ # ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members") @router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int): def list_members(request: Request, ws_id: int):
"""List all members of a workspace.""" """List all members of a workspace."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1022,7 +1022,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") @router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int): def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace.""" """Remove a member from a workspace."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -1036,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════ # ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse) @router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None): def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree. """Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?folder=ID is provided, shows that folder's contents with breadcrumb.
@@ -1084,7 +1084,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree") @router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None): def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace. """Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children. If ?folder=ID is provided, returns only that folder's children.
@@ -1109,7 +1109,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
@router.get("/api/local-workspace/page-content/{page_id:int}") @router.get("/api/local-workspace/page-content/{page_id:int}")
async def get_page_content(page_id: int): def get_page_content(page_id: int):
"""Return the raw content of a page (for preview).""" """Return the raw content of a page (for preview)."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -1174,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@router.get("/api/pages/{page_id}/download") @router.get("/api/pages/{page_id}/download")
async def download_page_file(request: Request, page_id: int): def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment).""" """Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id) _require_page_view(request, page_id)
with get_conn() as conn: with get_conn() as conn:
@@ -1197,7 +1197,7 @@ async def download_page_file(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/file-content") @router.get("/api/pages/{page_id}/file-content")
async def page_file_content(request: Request, page_id: int): def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard). """Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
@@ -1225,7 +1225,7 @@ async def page_file_content(request: Request, page_id: int):
@router.get("/api/local-workspace/breadcrumb") @router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int): def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder.""" """Return breadcrumb trail for a folder."""
with get_conn() as conn: with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder) breadcrumb = _build_breadcrumb(conn, folder)
@@ -1393,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@router.get("/api/nav/menu") @router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None): def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu. """Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the If ``parent_id`` is given, returns that page's children; otherwise the
@@ -1483,7 +1483,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}") @router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int): def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at).""" """Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime from datetime import datetime
with get_conn() as conn: with get_conn() as conn:
@@ -1496,7 +1496,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore") @router.post("/api/local-workspace/items/{item_id:int}/restore")
async def restore_local_workspace_item(request: Request, item_id: int): def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder.""" """Restore a soft-deleted file/folder."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -1508,7 +1508,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}") @router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str): def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk.""" """Serve an uploaded file from disk."""
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
@@ -1774,7 +1774,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse) @router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request): def workspaces_page(request: Request):
"""Workspaces list page.""" """Workspaces list page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces") @router.get("/api/workspaces")
async def list_workspaces(request: Request): def list_workspaces(request: Request):
"""List all workspaces for the current user.""" """List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1 uid = user["id"] if user and user.get("id") else 1
@@ -1855,7 +1855,7 @@ async def rename_workspace(request: Request, ws_id: int):
@router.delete("/api/workspaces/{ws_id:int}") @router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int): def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages.""" """Delete a workspace and all its pages."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
@@ -1866,7 +1866,7 @@ async def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select") @router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int): def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie.""" """Set the active workspace via cookie."""
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════ # ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse) @router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request): def app_settings_page(request: Request):
"""Settings & configuration page.""" """Settings & configuration page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}") @router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str): def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file.""" """Serve an uploaded avatar image file."""
from pathlib import Path from pathlib import Path
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
@router.get("/api/avatar/{user_id:int}") @router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int): def get_avatar(user_id: int):
"""Redirect to the user's avatar.""" """Redirect to the user's avatar."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -2002,7 +2002,7 @@ async def update_tag_global(tag_id: int, request: Request):
@router.delete("/api/settings/tags/{tag_id:int}") @router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int, request: Request): def delete_tag_global(tag_id: int, request: Request):
"""Delete a tag — only if owned by user.""" """Delete a tag — only if owned by user."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -2013,7 +2013,7 @@ async def delete_tag_global(tag_id: int, request: Request):
@router.get("/api/settings/tags/all") @router.get("/api/settings/tags/all")
async def list_all_tags_global(request: Request): def list_all_tags_global(request: Request):
"""List current user's tags with counts.""" """List current user's tags with counts."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════ # ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags") @router.get("/api/local-workspace/tags")
async def list_tags(request: Request): def list_tags(request: Request):
"""List ALL user tags with counts scoped to the active workspace.""" """List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
@@ -2055,7 +2055,7 @@ async def list_tags(request: Request):
@router.get("/api/local-workspace/items/{item_id:int}/tags") @router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int): def get_item_tags(item_id: int):
"""Get tags for a specific item.""" """Get tags for a specific item."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -2104,7 +2104,7 @@ async def add_item_tag(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") @router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int): def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item.""" """Remove a tag from an item."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search") @router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""): def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated).""" """Search items by tags (comma-separated)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════ # ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree") @router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request): def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment. """Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations Called by appState().refreshSidebarTree() after CRUD operations
@@ -2260,7 +2260,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse) @router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str): def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required.""" """Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV from app.templating import ENV
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════ # ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content") @router.get("/api/pages/{page_id:int}/content")
async def api_page_content(page_id: int): def api_page_content(page_id: int):
"""Get page content for side peek preview.""" """Get page content for side peek preview."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -2563,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
@router.post("/api/pages/{page_id:int}/trash") @router.post("/api/pages/{page_id:int}/trash")
async def api_trash_page(page_id: int): def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash).""" """Soft-delete a page (move to trash)."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -2641,7 +2641,7 @@ async def api_convert_to_database(page_id: int, request: Request):
@router.get("/api/collections/{collection_id:int}/table-data") @router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int): def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view.""" """Get collection properties + pages for rendering the table view."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute( coll = conn.execute(
+2 -2
View File
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
@router.get("/api/custom-emojis") @router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request): def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis.""" """List the current workspace's custom emojis."""
ws_id = _active_ws(request) ws_id = _active_ws(request)
with get_conn() as conn: with get_conn() as conn:
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
@router.delete("/api/custom-emojis/{emoji_id}") @router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int): def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file).""" """Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request) ws_id = _active_ws(request)
with get_conn() as conn: with get_conn() as conn:
+4 -4
View File
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}") @router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request): def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
md = page_to_markdown(page) md = page_to_markdown(page)
filename = _safe_filename(page, "md") filename = _safe_filename(page, "md")
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}") @router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request): def export_html(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page) html = page_to_standalone_html(page)
filename = _safe_filename(page, "html") filename = _safe_filename(page, "html")
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}") @router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request): def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
try: try:
pdf_bytes = page_to_pdf_bytes(page) pdf_bytes = page_to_pdf_bytes(page)
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}") @router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request): def export_site(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
site_bytes = build_static_site_bytes(page) site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site" title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
+5 -5
View File
@@ -162,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
# ── Account linking ── # ── Account linking ──
@router.get("/status") @router.get("/status")
async def gitea_status(request: Request): def gitea_status(request: Request):
"""Check if the current user has Gitea linked.""" """Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request) client = get_user_gitea_client(request)
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
@router.delete("/disconnect") @router.delete("/disconnect")
async def disconnect_gitea(request: Request): def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user.""" """Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -186,7 +186,7 @@ async def disconnect_gitea(request: Request):
# ── Private Pages (local FlowDeck pages linked to Gitea project) ── # ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages") @router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request): def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project.""" """List private pages for this Gitea project."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -225,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}") @router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request): def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page.""" """Get a single private page."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -272,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}") @router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request): def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page.""" """Delete a private page."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
+2 -2
View File
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status") @router.get("/status")
async def github_status(request: Request): def github_status(request: Request):
"""Check if the current user has GitHub linked.""" """Check if the current user has GitHub linked."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -22,7 +22,7 @@ async def github_status(request: Request):
@router.delete("/disconnect") @router.delete("/disconnect")
async def disconnect_github(request: Request): def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user.""" """Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
+2 -2
View File
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
@router.get("/api/v2/agent-policies") @router.get("/api/v2/agent-policies")
async def list_policies(request: Request): def list_policies(request: Request):
_owner_or_admin(request) _owner_or_admin(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall() rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
@router.get("/api/v2/agent-approvals") @router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request): def list_approvals(request: Request):
_owner_or_admin(request) _owner_or_admin(request)
status = request.query_params.get("status", "pending") status = request.query_params.get("status", "pending")
with get_conn() as conn: with get_conn() as conn:
+5 -5
View File
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
@router.get("/sources") @router.get("/sources")
async def import_sources(request: Request): def import_sources(request: Request):
"""List every available importer for the UI source picker.""" """List every available importer for the UI source picker."""
return {"sources": list_sources()} return {"sources": list_sources()}
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
@router.post("/relations/resolve") @router.post("/relations/resolve")
async def import_resolve_relations(request: Request): def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props.""" """Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request) ws_id, _ = _workspace(request)
with get_conn() as conn: with get_conn() as conn:
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
@router.get("/jobs") @router.get("/jobs")
async def import_jobs(request: Request): def import_jobs(request: Request):
return {"jobs": list_jobs()} return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}") @router.get("/jobs/{job_id}")
async def import_job(job_id: str): def import_job(job_id: str):
job = get_job(job_id) job = get_job(job_id)
if not job: if not job:
raise HTTPException(404, "Job not found") raise HTTPException(404, "Job not found")
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
@router.get("/jobs/{job_id}/report") @router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str): def import_job_report(job_id: str):
"""Download a job's import report as JSON.""" """Download a job's import report as JSON."""
job = get_job(job_id) job = get_job(job_id)
if not job: if not job:
+10 -10
View File
@@ -157,7 +157,7 @@ BASE_SELECT = (
@router.get("/recents") @router.get("/recents")
async def library_recents( def library_recents(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -191,7 +191,7 @@ async def library_recents(
@router.get("/favorites") @router.get("/favorites")
async def library_favorites( def library_favorites(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -221,7 +221,7 @@ async def library_favorites(
@router.get("/shared") @router.get("/shared")
async def library_shared( def library_shared(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -292,7 +292,7 @@ async def library_shared(
@router.get("/published") @router.get("/published")
async def library_published( def library_published(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -317,7 +317,7 @@ async def library_published(
@router.get("/private") @router.get("/private")
async def library_private( def library_private(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -342,7 +342,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}") @router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request): def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion.""" """Return children of a local workspace item for tree expansion."""
_get_user_id(request) _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -410,7 +410,7 @@ async def library_local_workspace_children(item_id: int, request: Request):
@router.get("/children/{page_id:int}") @router.get("/children/{page_id:int}")
async def library_children(page_id: int, request: Request): def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library).""" """Return child pages for a given parent page (for tree expansion in Library)."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -426,7 +426,7 @@ async def library_children(page_id: int, request: Request):
@router.get("/repository") @router.get("/repository")
async def library_repository( def library_repository(
request: Request, request: Request,
gitea_owner: str = Query(default=""), gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""), gitea_repo: str = Query(default=""),
@@ -450,7 +450,7 @@ async def library_repository(
@router.get("/local-workspace") @router.get("/local-workspace")
async def library_local_workspace( def library_local_workspace(
request: Request, request: Request,
workspace_id: int = Query(default=0), workspace_id: int = Query(default=0),
): ):
@@ -535,7 +535,7 @@ def _format_size(size_bytes):
@router.get("/workspace") @router.get("/workspace")
async def library_workspace( def library_workspace(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
+3 -3
View File
@@ -75,13 +75,13 @@ async def create_link(request: Request):
@router.get("/api/v2/calendar-links") @router.get("/api/v2/calendar-links")
async def get_links(request: Request): def get_links(request: Request):
user = _auth_user(request) user = _auth_user(request)
return {"links": cal.list_links(user["id"])} return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}") @router.delete("/api/v2/calendar-links/{link_id}")
async def remove_link(link_id: int, request: Request): def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id): if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found") raise HTTPException(404, "Link not found")
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
# ── free/busy ────────────────────────────────────────────────────────────── # ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy") @router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request): def freebusy(collection_id: int, request: Request):
_auth_user(request) _auth_user(request)
qp = request.query_params qp = request.query_params
try: try:
+2 -2
View File
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7): def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections.""" """My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request) user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin" user_login = user.get("login", "admin") if user else "admin"
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
@router.get("/api") @router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7): def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON.""" """API: return my tasks as JSON."""
user = _get_current_user(request) user = _get_current_user(request)
user.get("login", "admin") if user else "admin" user.get("login", "admin") if user else "admin"
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def get_notes(request: Request, owner: str, repo: str): def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'", "SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
+5 -5
View File
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
@router.get("") @router.get("")
async def list_notifications(request: Request, limit: int = 50): def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first.""" """List the current user's notifications, newest first."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
@router.get("/unread-count") @router.get("/unread-count")
async def unread_count(request: Request): def unread_count(request: Request):
"""Unread count for the topbar badge.""" """Unread count for the topbar badge."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
@router.get("/prefs") @router.get("/prefs")
async def get_prefs(request: Request): def get_prefs(request: Request):
"""Return the current user's notification email preferences.""" """Return the current user's notification email preferences."""
user = _current_user(request) user = _current_user(request)
from app.services import notifications as notif from app.services import notifications as notif
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
@router.get("/timezone") @router.get("/timezone")
async def get_timezone(request: Request): def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC).""" """Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
@router.get("/users/search") @router.get("/users/search")
async def search_users(request: Request, q: str = ""): def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions.""" """User autocomplete for @mentions."""
_current_user(request) _current_user(request)
q = (q or "").strip() q = (q or "").strip()
+1 -1
View File
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
@router.get("/welcome", response_class=HTMLResponse) @router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request): def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who """Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app.""" already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+14 -14
View File
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
@router.get("/pages/{page_id}/permissions") @router.get("/pages/{page_id}/permissions")
async def list_page_permissions(page_id: int, request: Request): def list_page_permissions(page_id: int, request: Request):
"""List explicit page grants + the caller's effective role.""" """List explicit page grants + the caller's effective role."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_page(page_id): if not pm.can_view_page(page_id):
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
@router.get("/pages/{page_id}/permissions/mine") @router.get("/pages/{page_id}/permissions/mine")
async def my_page_permission(page_id: int, request: Request): def my_page_permission(page_id: int, request: Request):
"""Effective role of the current user on a page (UI gating).""" """Effective role of the current user on a page (UI gating)."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_page(page_id): if not pm.can_view_page(page_id):
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
@router.delete("/pages/{page_id}/permissions/{perm_id}") @router.delete("/pages/{page_id}/permissions/{perm_id}")
async def revoke_page_permission(page_id: int, perm_id: int, request: Request): def revoke_page_permission(page_id: int, perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_page_permissions(page_id): if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions") raise HTTPException(403, "Only a page owner can manage its permissions")
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
@router.get("/collections/{collection_id}/permissions") @router.get("/collections/{collection_id}/permissions")
async def list_collection_permissions(collection_id: int, request: Request): def list_collection_permissions(collection_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found") raise HTTPException(404, "Collection not found")
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
@router.delete("/collections/{collection_id}/permissions/{perm_id}") @router.delete("/collections/{collection_id}/permissions/{perm_id}")
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request): def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id): if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage its permissions") raise HTTPException(403, "Only a collection owner can manage its permissions")
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/visible") @router.get("/collections/{collection_id}/properties/visible")
async def visible_properties(collection_id: int, request: Request): def visible_properties(collection_id: int, request: Request):
"""Split property ids into visible / hidden for the current user.""" """Split property ids into visible / hidden for the current user."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/{property_id}/permissions") @router.get("/collections/{collection_id}/properties/{property_id}/permissions")
async def list_property_permissions(collection_id: int, property_id: int, request: Request): def list_property_permissions(collection_id: int, property_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found") raise HTTPException(404, "Collection not found")
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}") @router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
async def revoke_property_permission(collection_id: int, property_id: int, def revoke_property_permission(collection_id: int, property_id: int,
perm_id: int, request: Request): perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id): if not pm.can_manage_collection_permissions(collection_id):
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
@router.get("/groups") @router.get("/groups")
async def list_groups(request: Request, workspace_id: int | None = None): def list_groups(request: Request, workspace_id: int | None = None):
user = _require_user(request) user = _require_user(request)
pm = PermissionManager(user["id"]) pm = PermissionManager(user["id"])
return {"groups": pm.get_groups_for_workspace(workspace_id)} return {"groups": pm.get_groups_for_workspace(workspace_id)}
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
@router.delete("/groups/{group_id}") @router.delete("/groups/{group_id}")
async def delete_group(group_id: int, request: Request): def delete_group(group_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
@router.get("/groups/{group_id}/members") @router.get("/groups/{group_id}/members")
async def list_group_members(group_id: int, request: Request): def list_group_members(group_id: int, request: Request):
user = _require_user(request) user = _require_user(request)
return {"members": PermissionManager(user["id"]).get_group_members(group_id)} return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
@router.delete("/groups/{group_id}/members/{user_id}") @router.delete("/groups/{group_id}/members/{user_id}")
async def remove_group_member(group_id: int, user_id: int, request: Request): def remove_group_member(group_id: int, user_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
@router.get("/users") @router.get("/users")
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""): def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
"""Workspace members (+ admins) for the grant pickers.""" """Workspace members (+ admins) for the grant pickers."""
_require_user(request) _require_user(request)
q = (q or "").strip().lower() q = (q or "").strip().lower()
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
@router.get("/audit/permissions") @router.get("/audit/permissions")
async def permission_audit(request: Request, limit: int = 100): def permission_audit(request: Request, limit: int = 100):
"""Full permission change history — workspace owner/admin only.""" """Full permission change history — workspace owner/admin only."""
user = _require_user(request) user = _require_user(request)
uid = user["id"] uid = user["id"]
+1 -1
View File
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
@router.get("") @router.get("")
async def list_projects(request: Request): def list_projects(request: Request):
"""List all synced projects (optionally filtered by type).""" """List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)} return {"projects": projects_svc.list_projects(proj_type)}
+6 -6
View File
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token") @router.post("/token")
async def generate_token(request: Request): def generate_token(request: Request):
"""Generate a public API access token (A4 : session obligatoire — plus de """Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous).""" « legacy shared token » `user_id=0` créable par un anonymous)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
@router.get("/collections", dependencies=[Depends(verify_token)]) @router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request): def public_list_collections(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall() rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]} return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)]) @router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int): def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll: if not coll:
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)]) @router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int): def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position", "SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)]) @router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int): def public_get_page(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p: if not p:
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
@router.get("/my-tasks", dependencies=[Depends(verify_token)]) @router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request): def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token).""" """Public API: list tasks (requires valid token)."""
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
@router.get("/api/realtime/stats") @router.get("/api/realtime/stats")
async def realtime_stats(request: Request): def realtime_stats(request: Request):
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits. """Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
Réservé aux utilisateurs authentifiés (données d'activité internes). Réservé aux utilisateurs authentifiés (données d'activité internes).
+7 -7
View File
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
# ── SCIM resources ───────────────────────────────────────────────────────── # ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users") @router.get("/scim/v2/Users")
async def scim_list(request: Request): def scim_list(request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall() rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
@router.get("/scim/v2/Users/{user_id}") @router.get("/scim/v2/Users/{user_id}")
async def scim_get(user_id: str, request: Request): def scim_get(user_id: str, request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
@router.delete("/scim/v2/Users/{user_id}") @router.delete("/scim/v2/Users/{user_id}")
async def scim_delete(user_id: str, request: Request): def scim_delete(user_id: str, request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
@router.get("/api/v2/scim/tokens") @router.get("/api/v2/scim/tokens")
async def list_scim_tokens(request: Request): def list_scim_tokens(request: Request):
_admin_session(request) _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, created_by, revoked, created_at" rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
@router.delete("/api/v2/scim/tokens/{token_id}") @router.delete("/api/v2/scim/tokens/{token_id}")
async def revoke_scim_token(token_id: int, request: Request): def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,)) conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
# ── domain claims ────────────────────────────────────────────────────────── # ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims") @router.get("/api/v2/domain-claims")
async def list_domains(request: Request): def list_domains(request: Request):
_admin_session(request) _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall() rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
@router.delete("/api/v2/domain-claims/{domain_id}") @router.delete("/api/v2/domain-claims/{domain_id}")
async def delete_domain(domain_id: int, request: Request): def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,)) conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
+1 -1
View File
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
@router.get("/api/search") @router.get("/api/search")
async def search(request: Request, q: str = Query(default="")): def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None user_id = user.get("id") if user and user.get("id") else None
+2 -2
View File
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
@router.get("/api/v2/search/hybrid") @router.get("/api/v2/search/hybrid")
async def hybrid(request: Request): def hybrid(request: Request):
user = _auth_user(request) user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip() q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q: if not q:
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
@router.get("/api/v2/search/index-status") @router.get("/api/v2/search/index-status")
async def index_status(request: Request): def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility).""" """How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
+4 -4
View File
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
@router.get("/tokens") @router.get("/tokens")
async def list_tokens(request: Request): def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets).""" """List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -63,7 +63,7 @@ async def create_token(request: Request):
@router.delete("/tokens/{token_id:int}") @router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request): def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete).""" """Revoke an API token (soft delete)."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
@router.get("/sessions") @router.get("/sessions")
async def list_sessions(request: Request): def list_sessions(request: Request):
"""List the current user's active sessions with their devices.""" """List the current user's active sessions with their devices."""
uid = _current_user_id(request) uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", "")) current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
@router.post("/sessions/{sid}/revoke") @router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request): def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out.""" """Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
+2 -2
View File
@@ -204,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
@router.delete("/pages/{page_id}/share/{share_id}") @router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request): def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation.""" """Remove a share invitation."""
_require_auth(request) _require_auth(request)
@@ -238,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
@router.get("/pages/{page_id}/shares") @router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request): def list_shares(page_id: int, request: Request):
"""Get all shares for a page.""" """Get all shares for a page."""
_require_auth(request) _require_auth(request)
+1 -1
View File
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
@router.get("/config") @router.get("/config")
async def get_sidebar_config(request: Request): def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config.""" """Get the current user's sidebar customization config."""
user = _get_user(request) user = _get_user(request)
with get_conn() as conn: with get_conn() as conn:
+11 -11
View File
@@ -315,7 +315,7 @@ async def create_site(request: Request):
@router.get("/api/v2/sites") @router.get("/api/v2/sites")
async def list_sites(request: Request): def list_sites(request: Request):
user = _auth_user(request) user = _auth_user(request)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
@router.get("/api/v2/sites/{site_id}") @router.get("/api/v2/sites/{site_id}")
async def get_site(site_id: int, request: Request): def get_site(site_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}") @router.delete("/api/v2/sites/{site_id}")
async def delete_site(site_id: int, request: Request): def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/pages") @router.get("/api/v2/sites/{site_id}/pages")
async def list_site_pages(site_id: int, request: Request): def list_site_pages(site_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}") @router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async def remove_site_page(site_id: int, page_id: int, request: Request): def remove_site_page(site_id: int, page_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/stats") @router.get("/api/v2/sites/{site_id}/stats")
async def site_stats(site_id: int, request: Request, days: int = 30): def site_stats(site_id: int, request: Request, days: int = 30):
user = _auth_user(request) user = _auth_user(request)
days = max(1, min(int(days or 30), 365)) days = max(1, min(int(days or 30), 365))
with get_conn() as conn: with get_conn() as conn:
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
@router.get("/s/{slug}", response_class=HTMLResponse) @router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str): def public_site_home(request: Request, slug: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug, site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", "")) host=request.headers.get("host", ""))
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse) @router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async def site_sitemap(request: Request, slug: str): def site_sitemap(request: Request, slug: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug) site = _resolve_site(conn, slug=slug)
if not site or _site_expired(site) or site.get("password_hash"): if not site or _site_expired(site) or site.get("password_hash"):
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse) @router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async def public_site_page(request: Request, slug: str, page_ref: str): def public_site_page(request: Request, slug: str, page_ref: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", "")) site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site: if not site:
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
# ── Public Forms ─────────────────────────────────────────────────────────── # ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form") @router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request): def get_form_config(collection_id: int, request: Request):
_auth_user(request) _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
info = _form_config(conn, collection_id) info = _form_config(conn, collection_id)
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
@router.get("/f/{token}", response_class=HTMLResponse) @router.get("/f/{token}", response_class=HTMLResponse)
async def public_form(request: Request, token: str): def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM collections").fetchone() row = conn.execute("SELECT * FROM collections").fetchone()
+3 -3
View File
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
@router.get("/auth/saml/login") @router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT): def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP.""" """SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"): if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429) return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
@router.get("/auth/saml/metadata") @router.get("/auth/saml/metadata")
async def saml_metadata(request: Request): def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…).""" """SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error() cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml": if not cfg or cfg["provider_type"] != "saml":
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
@router.get("/api/v2/sso/providers") @router.get("/api/v2/sso/providers")
async def sso_providers(request: Request): def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag).""" """Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error() cfg = _sso_config_or_error()
if not cfg: if not cfg:
+1 -1
View File
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
@router.get("/status") @router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None), def sync_status(request: Request, workspace_id: int = Query(default=None),
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync).""" """Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request, authorization, required_scope="read") user = _user(request, authorization, required_scope="read")
+1 -1
View File
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
# ── HTML: /extensions download page ── # ── HTML: /extensions download page ──
@router.get("/extensions", response_class=HTMLResponse) @router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request): def extensions_page(request: Request):
from app.routers.dashboard import _sidebar_data from app.routers.dashboard import _sidebar_data
from app.templating import ENV from app.templating import ENV
+3 -3
View File
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
@router.post("/register/begin") @router.post("/register/begin")
async def register_begin(request: Request): def register_begin(request: Request):
if not _require_lib(): if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed") raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json from webauthn import generate_registration_options, options_to_json
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
@router.get("/keys") @router.get("/keys")
async def list_keys(request: Request): def list_keys(request: Request):
user = _session_user(request) user = _session_user(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials" rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
@router.delete("/keys/{key_id}") @router.delete("/keys/{key_id}")
async def delete_key(key_id: int, request: Request): def delete_key(key_id: int, request: Request):
user = _session_user(request) user = _session_user(request)
with get_conn() as conn: with get_conn() as conn:
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?", cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
+17 -17
View File
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
# ── teamspaces ───────────────────────────────────────────────────────────── # ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces") @router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request): def list_teamspaces(request: Request):
user = _user(request) user = _user(request)
wid = request.query_params.get("workspace_id") wid = request.query_params.get("workspace_id")
if wid: if wid:
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse) @router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async def teamspace_page(teamspace_id: int, request: Request): def teamspace_page(teamspace_id: int, request: Request):
"""Teamspace detail HTML page — sidebar entry point.""" """Teamspace detail HTML page — sidebar entry point."""
user = _user(request) user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"]) ts = _teamspace_or_404(teamspace_id, user["id"])
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}") @router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async def get_teamspace(teamspace_id: int, request: Request): def get_teamspace(teamspace_id: int, request: Request):
user = _user(request) user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"]) ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id) ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members") @router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request): def list_members(teamspace_id: int, request: Request):
user = _user(request) user = _user(request)
_teamspace_or_404(teamspace_id, user["id"]) _teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn: with get_conn() as conn:
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}") @router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def remove_member(teamspace_id: int, member_id: int, request: Request): def remove_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request) user = _user(request)
_teamspace_or_404(teamspace_id, user["id"]) _teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id): if not wiki.can_write_teamspace(user["id"], teamspace_id):
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
# ── verified pages ───────────────────────────────────────────────────────── # ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification") @router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request): def get_verification(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
return {"verification": wiki.verification(page_id)} return {"verification": wiki.verification(page_id)}
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
@router.delete("/api/v2/wiki/pages/{page_id}/verify") @router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request): def unverify_page(page_id: int, request: Request):
user = _user(request) user = _user(request)
_page_or_404(page_id) _page_or_404(page_id)
if not wiki.unverify_page(page_id): if not wiki.unverify_page(page_id):
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/verified") @router.get("/api/v2/wiki/verified")
async def list_verified(request: Request): def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index.""" """Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request) user = _user(request)
wid = _workspace_id(request) wid = _workspace_id(request)
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
# ── follows ──────────────────────────────────────────────────────────────── # ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow") @router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request): def follow_page(page_id: int, request: Request):
user = _user(request) user = _user(request)
_page_or_404(page_id) _page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"]) now = wiki.toggle_follow(page_id, user["id"])
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/followers") @router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request): def list_followers(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
ids = wiki.followers(page_id) ids = wiki.followers(page_id)
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
@router.get("/api/v2/wiki/comments/{comment_id}/reactions") @router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request): def list_reactions(comment_id: int, request: Request):
_user(request) _user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)} return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/guests") @router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request): def list_guests(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
with get_conn() as conn: with get_conn() as conn:
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
@router.delete("/api/v2/wiki/guests/{share_id}") @router.delete("/api/v2/wiki/guests/{share_id}")
async def revoke_guest(share_id: int, request: Request): def revoke_guest(share_id: int, request: Request):
user = _user(request) user = _user(request)
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone(): if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
@router.get("/g/{token}", response_class=HTMLResponse) @router.get("/g/{token}", response_class=HTMLResponse)
async def guest_page(token: str, request: Request): def guest_page(token: str, request: Request):
"""Account-less page access (read-only or commenter). 404 if inactive.""" """Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token) share = wiki.resolve_guest_share(token)
if not share: if not share:
@@ -465,7 +465,7 @@ Editing is disabled.</div>
# ── page views ───────────────────────────────────────────────────────────── # ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views") @router.get("/api/v2/wiki/pages/{page_id}/views")
async def page_views(page_id: int, request: Request): def page_views(page_id: int, request: Request):
user = _user(request) user = _user(request)
page = _page_or_404(page_id) page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]): if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
# ── wiki home ────────────────────────────────────────────────────────────── # ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home") @router.get("/api/v2/wiki/home")
async def wiki_home(request: Request): def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces.""" """Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request) user = _user(request)
wid = _workspace_id(request) wid = _workspace_id(request)
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
@router.post("/api/v2/wiki/verify-expiry-sweep") @router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request): def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job).""" """Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request) user = _user(request)
with get_conn() as conn: with get_conn() as conn:
+6 -6
View File
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
@router.get("/api/v2/workers") @router.get("/api/v2/workers")
async def list_workers(request: Request): def list_workers(request: Request):
_auth_user(request) _auth_user(request)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
@router.get("/api/v2/workers/{worker_id}") @router.get("/api/v2/workers/{worker_id}")
async def get_worker(worker_id: int, request: Request): def get_worker(worker_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
@router.delete("/api/v2/workers/{worker_id}") @router.delete("/api/v2/workers/{worker_id}")
async def delete_worker(worker_id: int, request: Request): def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers/{worker_id}/runs") @router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request): def worker_runs(worker_id: int, request: Request):
_auth_user(request) _auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20) limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn: with get_conn() as conn:
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
@router.post("/api/v2/workers/{worker_id}/fork") @router.post("/api/v2/workers/{worker_id}/fork")
async def fork_worker_endpoint(worker_id: int, request: Request): def fork_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
out = worker_service.fork_worker(worker_id, user["id"]) out = worker_service.fork_worker(worker_id, user["id"])
audit_log(user, "worker.fork", "worker", worker_id, "", request) audit_log(user, "worker.fork", "worker", worker_id, "", request)
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers-usage") @router.get("/api/v2/workers-usage")
async def workers_usage(request: Request): def workers_usage(request: Request):
user = _auth_user(request) user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id") ws_raw = request.query_params.get("workspace_id")
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
+19 -19
View File
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
# ── Members ── # ── Members ──
@router.get("/{ws_id}/members") @router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int): def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required") raise HTTPException(401, "Authentication required")
with get_conn() as conn: with get_conn() as conn:
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/{ws_id}/members/{user_id}") @router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int): def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id) _require_ws_admin(request, ws_id)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ── Comments ── # ── Comments ──
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): def list_comments(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at", "SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
# ── Page History ── # ── Page History ──
@router.get("/pages/{page_id}/history") @router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int): def page_history(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50", "SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
# ── Favorites ── # ── Favorites ──
@router.get("/favorites") @router.get("/favorites")
async def list_favorites(request: Request): def list_favorites(request: Request):
user = _current_user(request) user = _current_user(request)
uid = user.get("id", 1) uid = user.get("id", 1)
with get_conn() as conn: with get_conn() as conn:
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
@router.delete("/favorites/{fav_id}") @router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int): def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,)) conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit() conn.commit()
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
# ── Templates ── # ── Templates ──
@router.get("/templates/database") @router.get("/templates/database")
async def list_db_templates(request: Request): def list_db_templates(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall() rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]} return {"templates": [dict(r) for r in rows]}
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
@router.get("/collections/{collection_id}/templates/page") @router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int): def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,) "SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
@router.delete("/collections/{collection_id}/templates/page/{tid}") @router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int): def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template.""" """Delete a page template."""
with get_conn() as conn: with get_conn() as conn:
tmpl = conn.execute( tmpl = conn.execute(
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
# ── v4.2.0: Dashboards ── # ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards") @router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int): def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection.""" """List all dashboards for a collection."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
@router.delete("/collections/{collection_id}/dashboards/{did}") @router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int): def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard.""" """Delete a dashboard."""
with get_conn() as conn: with get_conn() as conn:
dash = conn.execute( dash = conn.execute(
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
# ── v4.5.0: Sprints ── # ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints") @router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int): def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection.""" """List all sprints for a collection."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}") @router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int): def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint.""" """Delete a sprint."""
with get_conn() as conn: with get_conn() as conn:
sprint = conn.execute( sprint = conn.execute(
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}") @router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int): def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint.""" """Remove a page from a sprint."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
@router.get("/collections/{collection_id}/sprints/burndown/{sid}") @router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int): def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint.""" """Calculate burndown data for a sprint."""
with get_conn() as conn: with get_conn() as conn:
sprint = conn.execute( sprint = conn.execute(
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/export/csv") @router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int): def export_csv(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,) "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
# ── Webhooks Outbound Management ── # ── Webhooks Outbound Management ──
@router.get("/webhooks") @router.get("/webhooks")
async def list_webhooks(request: Request): def list_webhooks(request: Request):
_require_admin(request) _require_admin(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall() rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}") @router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int): def delete_webhook(request: Request, wh_id: int):
_require_admin(request) _require_admin(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,)) conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ── # ── Public Sharing ──
@router.get("/public/{collection_id}") @router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int): def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required. """Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent A18 : les bases ``restricted``/``private`` (``permission_type``) restent
+1 -1
View File
@@ -15,7 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download, open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################} ############################################################################}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════ /* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé. fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page Enregistré dans 'alpine:init' avec garde d'exécution : que la page
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px} .db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px} .db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
'use strict'; 'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button> </button>
{% endif %} {% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script> <script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()" <div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div> </div>
</header> </header>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return; if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true; if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -2
View File
@@ -10,7 +10,7 @@
############################################################################} ############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %} {% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */ /* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () { (function () {
var FD_ICONS = { var FD_ICONS = {
@@ -89,7 +89,7 @@
})(); })();
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () { (function () {
if (window.__fdIconPickerRegistered) return; if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true; window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div> </div>
</span> </span>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return; if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true; if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+1 -1
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;} .rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;} .rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */ /* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () { window.__fdRT = (function () {
+2 -2
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data"> <script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }} {{ page_data | tojson }}
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
if (!window.__fdEditorScriptsLoaded) { if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true; window.__fdEditorScriptsLoaded = true;
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() { function accountsData() {
return { return {
profile: { full_name: '', email: '' }, profile: { full_name: '', email: '' },
+2 -2
View File
@@ -204,7 +204,7 @@
</style> </style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #} {# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var defaultAPI = { var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ── // ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [ var FD_ADMIN_CMDS = [
+7 -4
View File
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; } body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; } body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style> </style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></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.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script> <script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers // Inject CSRF token into HTMX headers
(function() { (function() {
const getCsrf = () => { const getCsrf = () => {
@@ -2207,7 +2210,7 @@
</div> </div>
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var PALETTE_ACTIONS = [ var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } }, { id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2378,7 +2381,7 @@
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #} {# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script> </script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #} {# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return; if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) { window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}'; var owner = '{{ owner }}';
var repo = '{{ repo }}'; var repo = '{{ repo }}';
var initialView = '{{ initial_view }}'; var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper // ponytail: CSRF helper
function getCsrf() { function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/); const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;} .gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => { Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
+1 -1
View File
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() { function importWizard() {
return { return {
sources: [], sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer> </footer>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
+1 -1
View File
@@ -456,7 +456,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() { function libraryPage() {
return { return {
tab: 'recents', tab: 'recents',
+2 -2
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;} .preview-leave-end{transform:translateX(100%);opacity:0;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData'); console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() { window._wsInitData = (function() {
return { return {
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData; _wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length); console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script> </script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split" <div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)" @contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %} {% endblock %} {% block content %}
{% include "_database_table.html" %} {% include "_database_table.html" %}
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data // Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }}; window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }}; window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %} {% block topbar %}{% endblock %}
{% block content %} {% block content %}
{% include '_page_editor_content.html' %} {% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% include '_page_editor_scripts.html' %} {% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer> </footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script> <script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (window.katex) { if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) { document.querySelectorAll('div[data-katex]').forEach(function (el) {
+1 -1
View File
@@ -1191,7 +1191,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() { function settingsInit() {
return { return {
activeSection: 'account', activeSection: 'account',
+1 -1
View File
@@ -99,7 +99,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function tableView() { function tableView() {
return { return {
sortField: '', sortField: '',
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div> <div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function teamLoad() { function teamLoad() {
return {}; return {};
} }
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() { function trashData() {
return { return {
search: '', search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section> </section>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() { function onboarding() {
return { return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'], steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() { function workspacePage() {
return { return {
builtinProjects: [], builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() { function workspacesPage() {
return { return {
workspaces: [], workspaces: [],
+7
View File
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
from __future__ import annotations from __future__ import annotations
import pathlib import pathlib
from contextvars import ContextVar
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
ENV = Environment( ENV = Environment(
loader=FileSystemLoader("app/templates"), loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]), autoescape=select_autoescape(["html"]),
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
ASSET_VERSION = "dev" ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
+397 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.6.0" "version": "7.9.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
@@ -16725,6 +16725,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -16974,6 +16985,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17123,6 +17145,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17233,6 +17266,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17397,6 +17441,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17462,6 +17517,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17617,6 +17683,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17727,6 +17804,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17837,6 +17925,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18003,6 +18102,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18454,6 +18564,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18564,6 +18685,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18674,6 +18806,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18849,6 +18992,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18905,6 +19059,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19015,6 +19180,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19062,6 +19238,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19109,6 +19296,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19275,6 +19473,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19385,6 +19594,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19495,6 +19715,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19551,6 +19782,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20125,6 +20367,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20365,6 +20618,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20421,6 +20685,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20531,6 +20806,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20809,6 +21095,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21085,6 +21382,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21361,6 +21669,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21592,6 +21911,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21648,6 +21978,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21861,6 +22202,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22367,6 +22719,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22618,6 +22981,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22674,6 +23048,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22961,6 +23346,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
+55
View File
@@ -215,6 +215,61 @@ def test_columns_helper_validates_table_name():
conn.close() conn.close()
def _assert_nonce(csp: str, html: str) -> str:
"""Header CSP : script-src sans unsafe-inline + TOUS les scripts inline noncés."""
import re as _re
m = _re.search(r"script-src ([^;]*);", csp)
assert m, csp
script_src = m.group(1)
nm = _re.search(r"'nonce-([^']+)'", script_src)
assert nm, script_src
nonce = nm.group(1)
assert "'unsafe-inline'" not in script_src, script_src
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
for mm in _re.finditer(r"<script[^>]*>", html)
if "src=" not in mm.group(0)
]
assert tags, "aucun script inline"
missing = [t for t in tags if f'nonce="{nonce}"' not in t]
assert missing == [], missing[:3]
return nonce
def test_csp_nonce_per_request(client):
"""A20 : nonce par requête — page base.html (avec meta htmx-config) et page
hors template (LOCAL_LOGIN_HTML, constante de module → nonce au rendu)."""
# 1) une page qui étend base.html (la meta htmx-config y est)
base = None
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
cand = client.get(url)
if cand.status_code == 200 and "htmx-config" in cand.text:
base = cand
break
assert base is not None, "aucune page base.html atteignable"
nonce = _assert_nonce(base.headers.get("content-security-policy", ""), base.text)
assert f'"inlineScriptNonce": "{nonce}"' in base.text
# deux requêtes = deux nonces différents
other = client.get("/dashboard")
if other.status_code == 200 and "htmx-config" in other.text:
other_nonce = _re_search_nonce(other.headers.get("content-security-policy", ""))
assert other_nonce != nonce
# 2) la page de login hors template (script injecté par _with_nonce)
r = client.get("/auth/login?provider=local")
assert r.status_code == 200, r.status_code
_assert_nonce(r.headers.get("content-security-policy", ""), r.text)
def _re_search_nonce(csp: str) -> str:
import re as _re
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app