Compare commits

...
4 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
bruno f706424f90 fix: A31 — transaction par migration + helper columns() (v7.6.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
  commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
  (isolation_level legacy) : un échec au milieu laissait un schéma partiel
  commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
  Si une transaction englobante subsiste (init_db commit juste avant), on la
  vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
  : 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
  éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
  `table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
  migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.

tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name

suite **1036/1036** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.6.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 10:13:23 -04:00
81 changed files with 1143 additions and 617 deletions
+94
View File
@@ -1,5 +1,99 @@
# 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)
### Fixed
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
laissait un schéma partiel commité SANS ligne de version, et la reprise
rejouait un DDL déjà appliqué
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
`ValueError` sinon) : **25 copies** de
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
se lit dans le set
### Tests
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed
+4 -4
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] **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**.*
- [ ] **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**.*
- [ ] **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] **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**.
- [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] **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**.*
@@ -1151,7 +1151,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
@@ -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.
*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.
→ **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.5.0
7.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.5.0 (audit — A29/A42 partiel) | **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`
## Avancement Global
+7 -7
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.5.0",
version="7.9.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@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)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
@@ -282,7 +282,7 @@ async def service_worker():
@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."""
import secrets
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
@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."""
from fastapi.responses import RedirectResponse
qs = str(request.url.query)
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
@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."""
from fastapi.responses import RedirectResponse
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)
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.
Registered on Starlette's HTTPException (the base class) so it catches both
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import ipaddress
import secrets
import time
from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""
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 = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
# 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:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
)
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)
# Only set CSP on HTML responses
content_type = response.headers.get("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
+61 -28
View File
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
)
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
unitaire se lit déjà dans le set.
"""
if not table.replace("_", "").isalnum():
raise ValueError(f"nom de table invalide: {table!r}")
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
_apply_one(conn, version, name, fn)
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
"""A31 : une migration = une transaction (DDL tout-ou-rien).
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
rollback complet à l'échec, donc la prochaine exécution retente proprement.
"""
if conn.in_transaction:
# transaction résiduelle du caller (init_db commit juste avant) — on
# part d'un état propre plutôt que d'englober son travail.
conn.commit()
conn.execute("BEGIN")
try:
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
except BaseException:
conn.rollback()
raise
# ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_pcols = columns(conn, "api_tokens")
if "id" not in _pcols:
conn.execute(
"""
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
_scols = columns(conn, "user_sessions")
if "id" not in _scols:
conn.execute(
"""
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
_projcols = columns(conn, "projects")
if "id" not in _projcols:
conn.execute(
"""
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
_cols = columns(conn, "database_templates")
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
_vcols = columns(conn, "collection_views")
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
_cpcols = columns(conn, "collection_pages")
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
_ucols = columns(conn, "users")
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
_ccols = columns(conn, "collections")
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
``idempotency_keys`` — Idempotency-Key support for POST creations.
"""
# api_tokens extra columns
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_cols = columns(conn, "api_tokens")
if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols:
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt).
"""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
_cols = columns(conn, "webhook_deliveries")
if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols:
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
cols = columns(conn, "collections")
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)"
)
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
auto_cols = columns(conn, "automations")
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
prop_cols = columns(conn, "collection_properties")
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
cols = columns(conn, "collection_pages")
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)"
)
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
user_cols = columns(conn, "users")
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
+4 -4
View File
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
# ── 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."""
from app.db import get_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}")
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."""
from app.db import get_conn
with get_conn() as conn:
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
# ── 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."""
from app.db import get_conn
with get_conn() as conn:
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
# ── 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."""
from app.db import get_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}")
async def get_conversation(request: Request, conversation_id: int):
def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
@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:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
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")
async def list_actions(request: Request, conversation_id: int):
def list_actions(request: Request, conversation_id: int):
with get_conn() as conn:
rows = conn.execute(
"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")
async def undo(request: Request, action_id: int):
def undo(request: Request, action_id: int):
try:
undo_action(action_id)
except ValueError as exc:
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
@router.get("/skills/gallery")
async def skills_gallery(request: Request):
def skills_gallery(request: Request):
presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets),
"install": "POST /api/agent/skills/gallery/{slug}/install"}
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
@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."""
with get_conn() as conn:
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}")
async def delete_skill(request: Request, skill_id: int):
def delete_skill(request: Request, skill_id: int):
with get_conn() as conn:
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
@router.get("/tools")
async def list_tools(request: Request):
def list_tools(request: Request):
registry = ToolRegistry()
tools = registry.schema()
return {"tools": tools}
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
@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:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row:
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
@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:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
+14 -14
View File
@@ -83,7 +83,7 @@ async def health(request: Request):
@router.get("/stats")
async def stats():
def stats():
"""Global stats for dashboard."""
with get_conn() as conn:
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")
async def set_col_mapping(
def set_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -209,7 +209,7 @@ async def set_col_mapping(
@router.delete("/col-mapping")
async def delete_col_mapping(
def delete_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -234,7 +234,7 @@ async def delete_col_mapping(
@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:
board = conn.execute(
"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}")
async def update_board_config(
def update_board_config(
owner: str,
repo: str,
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 ──
@router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist(
def create_checklist(
owner: str,
repo: str,
issue_id: int,
@@ -484,7 +484,7 @@ async def create_checklist(
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item(
def add_checklist_item(
owner: str,
repo: str,
issue_id: int,
@@ -502,7 +502,7 @@ async def add_checklist_item(
@router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item(
def toggle_checklist_item(
item_id: int,
checked: bool = Query(default=False),
content: str = Query(default=""),
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
@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."""
with get_conn() as conn:
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}")
async def delete_checklist(checklist_id: int):
def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items."""
with get_conn() as conn:
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 ──
@router.get("/users/me")
async def get_my_profile(request: Request):
def get_my_profile(request: Request):
"""Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
@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="")):
"""Update current user's local profile."""
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 ──
@router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card(
def update_card(
owner: str,
repo: str,
issue_id: int,
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
@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."""
errors = list(_frontend_errors)
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 ─────────────────────────────────────────────────────────────────
@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)
limit, offset = parse_pagination(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}")
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)
with get_conn() as conn:
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}")
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)
with get_conn() as conn:
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}) ────────
@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)
limit, offset = parse_pagination(request)
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}")
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)):
user = _guard(request, authorization)
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}")
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)):
user = _guard(request, authorization, write=True)
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")
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)):
user = _guard(request, authorization)
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")
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)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
# ── Skill marketplace ──────────────────────────────────────────────────────
@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)
limit, offset = parse_pagination(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
# FastAPI never tries to coerce "gallery" into an int path parameter.
@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)
presets = skill_gallery.list_gallery()
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}")
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)
with get_conn() as conn:
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")
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)):
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
_guard(request, authorization)
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
@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)):
user = _guard(request, authorization, write=True)
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")
async def audit_logs(request: Request):
def audit_logs(request: Request):
_admin_user(request)
qp = request.query_params
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.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__)
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")
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>
<html lang="en">
<head>
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@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)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"'
).replace(
@@ -163,16 +173,16 @@ async def register_page(request: Request):
).replace(
'id="submit-btn">Login<',
'id="submit-btn">Register<'
), status_code=200)
)), status_code=200)
@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."""
# Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse
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
from app.auth.providers import get_provider
@@ -450,7 +460,7 @@ async def callback(
@router.get("/logout")
async def logout(request: Request):
def logout(request: Request):
"""Clear session and redirect to login page.
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")
async def twofa_status(request: Request):
def twofa_status(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return {"enabled": _2fa.is_enabled(user["id"]),
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
@router.post("/2fa/setup")
async def twofa_setup(request: Request):
def twofa_setup(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return _2fa.setup_secret(user["id"])
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
@router.post("/2fa/disable")
async def twofa_disable(request: Request):
def twofa_disable(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
_2fa.disable(user["id"])
+6 -6
View File
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
@router.get("/workspace/automations")
async def list_automations(request: Request):
def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
@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:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
@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:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
@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:
rows = conn.execute(
"""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")
async def list_steps(request: Request, auto_id: int):
def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None:
return _auto_404()
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}")
async def delete_step(request: Request, step_id: int):
def delete_step(request: Request, step_id: int):
_require_session(request)
with get_conn() as conn:
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")
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
every non-deleted page the current user can see (single source: pages)."""
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")
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)."""
parsed: list[int] = []
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")
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."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
# ═══════════ Library page ═══════════
@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."""
from app.templating import ENV
env = ENV
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
# ═══════════ Favorites API ═══════════
@router.get("/api/favorites")
async def list_favorites(request: Request):
def list_favorites(request: Request):
"""List favorited page IDs for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
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) ═══════════
@router.get("/api/trash")
async def list_trash(request: Request):
def list_trash(request: Request):
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()
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}")
async def permanent_delete(request: Request, page_id: int):
def permanent_delete(request: Request, page_id: int):
with get_conn() as conn:
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,))
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
def trash_page(request: Request):
from app.templating import ENV
env = ENV
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.
@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."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
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}")
async def get_synced_block_api(sid: int):
def get_synced_block_api(sid: int):
"""Get a synced block by id."""
from app.services.synced_blocks import get_synced_block
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}")
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)."""
from app.services.synced_blocks import remove_page_synced
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")
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."""
from app.services.synced_blocks import get_page_synced
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 ═══════════
@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
env = ENV
sidebar = _sidebar_data(request, owner, repo)
@@ -1279,12 +1279,12 @@ async def board_view(
# ═══════════ v0.9.0: Custom Properties API ═══════════
@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)}
@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"),
options: str = Query(default="")):
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}")
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:
conn.execute(
"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")
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="")):
with get_conn() as conn:
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 ═══════════
@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:
rows = conn.execute(
"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}")
async def get_page(request: Request, page_id: int):
def get_page(request: Request, page_id: int):
"""Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
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")
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…").
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")
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."""
with get_conn() as conn:
rows = conn.execute(
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
@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:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit()
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
@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.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
+2 -2
View File
@@ -49,7 +49,7 @@ def _serialize(rows):
@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."""
_current_user(request)
with get_conn() as conn:
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
@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."""
user = _current_user(request)
with get_conn() as conn:
+29 -28
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
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)
async def list_collections(request: Request):
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
@router.get("/api")
async def list_collections_api(request: Request):
def list_collections_api(request: Request):
"""API: list all collections."""
with get_conn() as conn:
rows = conn.execute(
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
@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."""
with get_conn() as conn:
page = conn.execute(
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
@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).
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")
async def list_boards_as_collections(request: Request):
def list_boards_as_collections(request: Request):
"""API: list all Gitea boards as pseudo-collections."""
from app.services.collection_adapter import GiteaBoardCompat
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")
async def list_property_types_api(request: Request):
def list_property_types_api(request: Request):
"""API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES}
@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."""
user = _session_user(request)
_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")
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.
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")
async def collection_calendar_api(request: Request, collection_id: int,
def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""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")
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."""
user = _current_user(request)
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")
async def delete_property_api(request: Request, prop_id: int):
def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
@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."""
with get_conn() as conn:
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")
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.
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")
async def delete_view_api(request: Request, view_id: int):
def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
existing = conn.execute(
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
@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."""
with get_conn() as conn:
coll = conn.execute(
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
@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)."""
with get_conn() as conn:
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")
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)."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
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)
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).
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}/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."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
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" />
<div id="map"></div>
<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);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
@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."""
# v6.0.0: granular collection permissions — hide restricted collections.
_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)
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."""
from app.routers.board import _sidebar_data as board_sidebar
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)
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).
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)
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.
?embed=1 — minimal mode for side peek (editor only, no header)."""
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)
async def accounts_page(request: Request):
def accounts_page(request: Request):
"""Account management panel."""
from app.templating import ENV
env = ENV
@@ -451,7 +451,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
def help_page(request: Request):
"""Comprehensive help & documentation page."""
from app.templating import 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)
async def settings_page(request: Request):
def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from app.templating import ENV
env = ENV
@@ -731,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
def generate_token(request: Request):
import secrets
uid = _require_user_id(request)
token = secrets.token_hex(32)
@@ -745,7 +745,7 @@ async def generate_token(request: Request):
@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)
with get_conn() as conn:
conn.execute(
@@ -834,7 +834,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from app.templating import ENV
env = ENV
@@ -851,7 +851,7 @@ async def workspace_page(request: Request):
@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."""
import json
@@ -971,7 +971,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════
@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."""
with get_conn() as conn:
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}")
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."""
with get_conn() as conn:
conn.execute(
@@ -1036,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@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.
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")
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.
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}")
async def get_page_content(page_id: int):
def get_page_content(page_id: int):
"""Return the raw content of a page (for preview)."""
with get_conn() as conn:
row = conn.execute(
@@ -1174,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@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)."""
_require_page_view(request, page_id)
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")
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).
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")
async def local_workspace_breadcrumb(request: Request, folder: int):
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
@@ -1393,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@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.
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}")
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)."""
from datetime import datetime
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")
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."""
with get_conn() as conn:
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}")
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."""
import mimetypes
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)
async def workspaces_page(request: Request):
def workspaces_page(request: Request):
"""Workspaces list page."""
from app.templating import ENV
env = ENV
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces")
async def list_workspaces(request: Request):
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
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}")
async def delete_workspace(request: Request, ws_id: int):
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
with get_conn() as conn:
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")
async def select_workspace(request: Request, ws_id: int):
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
def app_settings_page(request: Request):
"""Settings & configuration page."""
from app.templating import ENV
env = ENV
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
@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."""
from pathlib import Path
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
@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."""
with get_conn() as conn:
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}")
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."""
uid = _get_user_id(request)
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")
async def list_all_tags_global(request: Request):
def list_all_tags_global(request: Request):
"""List current user's tags with counts."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════
@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."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
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")
async def get_item_tags(item_id: int):
def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
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}")
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."""
with get_conn() as conn:
conn.execute(
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@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)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════
@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.
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)
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."""
from app.templating import ENV
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════
@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."""
with get_conn() as conn:
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")
async def api_trash_page(page_id: int):
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
with get_conn() as conn:
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")
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."""
with get_conn() as conn:
coll = conn.execute(
+2 -2
View File
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
@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."""
ws_id = _active_ws(request)
with get_conn() as conn:
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
@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)."""
ws_id = _active_ws(request)
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}")
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)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
@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)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
@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)
try:
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}")
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)
site_bytes = build_static_site_bytes(page)
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 ──
@router.get("/status")
async def gitea_status(request: Request):
def gitea_status(request: Request):
"""Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
@router.delete("/disconnect")
async def disconnect_gitea(request: Request):
def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager
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) ──
@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."""
from app.auth.session import SessionManager
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}")
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."""
from app.auth.session import SessionManager
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}")
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."""
from app.auth.session import SessionManager
from app.db import get_conn
+2 -2
View File
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status")
async def github_status(request: Request):
def github_status(request: Request):
"""Check if the current user has GitHub linked."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -22,7 +22,7 @@ async def github_status(request: Request):
@router.delete("/disconnect")
async def disconnect_github(request: Request):
def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager
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")
async def list_policies(request: Request):
def list_policies(request: Request):
_owner_or_admin(request)
with get_conn() as conn:
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")
async def list_approvals(request: Request):
def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
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")
async def import_sources(request: Request):
def import_sources(request: Request):
"""List every available importer for the UI source picker."""
return {"sources": list_sources()}
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
@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."""
ws_id, _ = _workspace(request)
with get_conn() as conn:
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
@router.get("/jobs")
async def import_jobs(request: Request):
def import_jobs(request: Request):
return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}")
async def import_job(job_id: str):
def import_job(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
@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."""
job = get_job(job_id)
if not job:
+10 -10
View File
@@ -157,7 +157,7 @@ BASE_SELECT = (
@router.get("/recents")
async def library_recents(
def library_recents(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -191,7 +191,7 @@ async def library_recents(
@router.get("/favorites")
async def library_favorites(
def library_favorites(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -221,7 +221,7 @@ async def library_favorites(
@router.get("/shared")
async def library_shared(
def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -292,7 +292,7 @@ async def library_shared(
@router.get("/published")
async def library_published(
def library_published(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -317,7 +317,7 @@ async def library_published(
@router.get("/private")
async def library_private(
def library_private(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -342,7 +342,7 @@ async def library_private(
@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."""
_get_user_id(request)
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}")
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)."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -426,7 +426,7 @@ async def library_children(page_id: int, request: Request):
@router.get("/repository")
async def library_repository(
def library_repository(
request: Request,
gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""),
@@ -450,7 +450,7 @@ async def library_repository(
@router.get("/local-workspace")
async def library_local_workspace(
def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
@@ -535,7 +535,7 @@ def _format_size(size_bytes):
@router.get("/workspace")
async def library_workspace(
def library_workspace(
request: Request,
source_type: str = Query(default="all"),
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")
async def get_links(request: Request):
def get_links(request: Request):
user = _auth_user(request)
return {"links": cal.list_links(user["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)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
# ── free/busy ──────────────────────────────────────────────────────────────
@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)
qp = request.query_params
try:
+2 -2
View File
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
@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."""
user = _get_current_user(request)
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")
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."""
user = _get_current_user(request)
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)
async def get_notes(request: Request, owner: str, repo: str):
def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn:
row = conn.execute(
"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("")
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."""
user = _current_user(request)
with get_conn() as conn:
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
@router.get("/unread-count")
async def unread_count(request: Request):
def unread_count(request: Request):
"""Unread count for the topbar badge."""
user = _current_user(request)
with get_conn() as conn:
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
@router.get("/prefs")
async def get_prefs(request: Request):
def get_prefs(request: Request):
"""Return the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
@router.get("/timezone")
async def get_timezone(request: Request):
def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
_current_user(request)
q = (q or "").strip()
+1 -1
View File
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
@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
already have a workspace straight to the app."""
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")
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."""
pm = _pm(request)
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")
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)."""
pm = _pm(request)
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}")
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)
if not pm.can_manage_page_permissions(page_id):
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")
async def list_collection_permissions(collection_id: int, request: Request):
def list_collection_permissions(collection_id: int, request: Request):
pm = _pm(request)
if not pm.can_view_collection(collection_id):
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}")
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)
if not pm.can_manage_collection_permissions(collection_id):
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")
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."""
pm = _pm(request)
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")
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)
if not pm.can_view_collection(collection_id):
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}")
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):
pm = _pm(request)
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")
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)
pm = PermissionManager(user["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}")
async def delete_group(group_id: int, request: Request):
def delete_group(group_id: int, request: Request):
pm = _pm(request)
with get_conn() as conn:
row = conn.execute(
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
@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)
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}")
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)
with get_conn() as conn:
row = conn.execute(
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
@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."""
_require_user(request)
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")
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."""
user = _require_user(request)
uid = user["id"]
+1 -1
View File
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
@router.get("")
async def list_projects(request: Request):
def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
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")
async def generate_token(request: Request):
def generate_token(request: Request):
"""Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous)."""
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)])
async def public_list_collections(request: Request):
def public_list_collections(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]}
@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:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
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)])
async def public_list_pages(request: Request, collection_id: int):
def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn:
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",
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
@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:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
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)])
async def public_my_tasks(request: Request):
def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token)."""
with get_conn() as conn:
pages = conn.execute(
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
@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.
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 ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users")
async def scim_list(request: Request):
def scim_list(request: Request):
_scim_guard(request)
with get_conn() as conn:
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}")
async def scim_get(user_id: str, request: Request):
def scim_get(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
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}")
async def scim_delete(user_id: str, request: Request):
def scim_delete(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
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")
async def list_scim_tokens(request: Request):
def list_scim_tokens(request: Request):
_admin_session(request)
with get_conn() as conn:
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}")
async def revoke_scim_token(token_id: int, request: Request):
def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
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 ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims")
async def list_domains(request: Request):
def list_domains(request: Request):
_admin_session(request)
with get_conn() as conn:
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}")
async def delete_domain(domain_id: int, request: Request):
def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
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")
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_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")
async def hybrid(request: Request):
def hybrid(request: Request):
user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q:
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
@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)."""
user = _auth_user(request)
with get_conn() as conn:
+4 -4
View File
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
@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)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -63,7 +63,7 @@ async def create_token(request: Request):
@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)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
@router.get("/sessions")
async def list_sessions(request: Request):
def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
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")
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."""
uid = _current_user_id(request)
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}")
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."""
_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")
async def list_shares(page_id: int, request: Request):
def list_shares(page_id: int, request: Request):
"""Get all shares for a page."""
_require_auth(request)
+1 -1
View File
@@ -35,7 +35,7 @@ DEFAULT_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."""
user = _get_user(request)
with get_conn() as conn:
+11 -11
View File
@@ -315,7 +315,7 @@ async def create_site(request: Request):
@router.get("/api/v2/sites")
async def list_sites(request: Request):
def list_sites(request: Request):
user = _auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
@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)
with get_conn() as conn:
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}")
async def delete_site(site_id: int, request: Request):
def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
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")
async def list_site_pages(site_id: int, request: Request):
def list_site_pages(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
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}")
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)
with get_conn() as conn:
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")
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)
days = max(1, min(int(days or 30), 365))
with get_conn() as conn:
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
@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:
site = _resolve_site(conn, slug=slug,
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)
async def site_sitemap(request: Request, slug: str):
def site_sitemap(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
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)
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:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site:
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
# ── Public Forms ───────────────────────────────────────────────────────────
@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)
with get_conn() as conn:
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)
async def public_form(request: Request, token: str):
def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1"
with get_conn() as conn:
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")
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."""
if not _rate_ok(request, "saml"):
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")
async def saml_metadata(request: Request):
def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error()
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")
async def sso_providers(request: Request):
def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error()
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")
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)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
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 ──
@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.templating import ENV
+3 -3
View File
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
@router.post("/register/begin")
async def register_begin(request: Request):
def register_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
@router.get("/keys")
async def list_keys(request: Request):
def list_keys(request: Request):
user = _session_user(request)
with get_conn() as conn:
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}")
async def delete_key(key_id: int, request: Request):
def delete_key(key_id: int, request: Request):
user = _session_user(request)
with get_conn() as conn:
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 ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request):
def list_teamspaces(request: Request):
user = _user(request)
wid = request.query_params.get("workspace_id")
if wid:
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
@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."""
user = _user(request)
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}")
async def get_teamspace(teamspace_id: int, request: Request):
def get_teamspace(teamspace_id: int, request: Request):
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["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")
async def list_members(teamspace_id: int, request: Request):
def list_members(teamspace_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
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}")
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)
_teamspace_or_404(teamspace_id, user["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 ─────────────────────────────────────────────────────────
@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)
_page_or_404(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")
async def unverify_page(page_id: int, request: Request):
def unverify_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(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")
async def list_verified(request: Request):
def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request)
wid = _workspace_id(request)
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
# ── follows ────────────────────────────────────────────────────────────────
@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)
_page_or_404(page_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")
async def list_followers(page_id: int, request: Request):
def list_followers(page_id: int, request: Request):
_user(request)
_page_or_404(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")
async def list_reactions(comment_id: int, request: Request):
def list_reactions(comment_id: int, request: Request):
_user(request)
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")
async def list_guests(page_id: int, request: Request):
def list_guests(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
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}")
async def revoke_guest(share_id: int, request: Request):
def revoke_guest(share_id: int, request: Request):
user = _user(request)
with get_conn() as conn:
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)
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."""
share = wiki.resolve_guest_share(token)
if not share:
@@ -465,7 +465,7 @@ Editing is disabled.</div>
# ── page 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)
page = _page_or_404(page_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 ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home")
async def wiki_home(request: Request):
def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request)
wid = _workspace_id(request)
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
@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)."""
user = _user(request)
with get_conn() as conn:
+6 -6
View File
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
@router.get("/api/v2/workers")
async def list_workers(request: Request):
def list_workers(request: Request):
_auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
@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)
with get_conn() as conn:
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}")
async def delete_worker(worker_id: int, request: Request):
def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
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")
async def worker_runs(worker_id: int, request: Request):
def worker_runs(worker_id: int, request: Request):
_auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20)
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")
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)
out = worker_service.fork_worker(worker_id, user["id"])
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")
async def workers_usage(request: Request):
def workers_usage(request: Request):
user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id")
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 ──
@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", "")):
raise HTTPException(401, "Authentication required")
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}")
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)
with get_conn() as conn:
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 ──
@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:
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",
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
# ── Page 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:
rows = conn.execute(
"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 ──
@router.get("/favorites")
async def list_favorites(request: Request):
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
@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:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
# ── Templates ──
@router.get("/templates/database")
async def list_db_templates(request: Request):
def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
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")
async def list_page_templates(request: Request, collection_id: int):
def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"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}")
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."""
with get_conn() as conn:
tmpl = conn.execute(
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
# ── v4.2.0: 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."""
with get_conn() as conn:
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}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
# ── v4.5.0: 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."""
with get_conn() as conn:
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}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
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}")
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."""
with get_conn() as conn:
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}")
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."""
with get_conn() as conn:
sprint = conn.execute(
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
@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:
pages = conn.execute(
"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 ──
@router.get("/webhooks")
async def list_webhooks(request: Request):
def list_webhooks(request: Request):
_require_admin(request)
with get_conn() as conn:
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}")
async def delete_webhook(request: Request, wh_id: int):
def delete_webhook(request: Request, wh_id: int):
_require_admin(request)
with get_conn() as conn:
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 ──
@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.
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,
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é.
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-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button>
{% 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()"
@mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div>
</header>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
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'] %}
<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. */
(function () {
var FD_ICONS = {
@@ -89,7 +89,7 @@
})();
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
</span>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
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-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
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 }}
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true;
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() {
return {
profile: { full_name: '', email: '' },
+2 -2
View File
@@ -204,7 +204,7 @@
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
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-cover-area { padding-top: 0 !important; }
</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/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
@@ -2207,7 +2210,7 @@
</div>
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
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; } },
@@ -2378,7 +2381,7 @@
{# ─── 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 data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -98,7 +98,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
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;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
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>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() {
return {
sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
+1 -1
View File
@@ -456,7 +456,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() {
return {
tab: 'recents',
+2 -2
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
return {
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</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"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %}
{% block content %}
{% 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 %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer>
<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 () {
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
+1 -1
View File
@@ -1191,7 +1191,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() {
return {
activeSection: 'account',
+1 -1
View File
@@ -99,7 +99,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function tableView() {
return {
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>
<script>
<script nonce="{{ csp_nonce() }}">
function teamLoad() {
return {};
}
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() {
return {
search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() {
return {
builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() {
return {
workspaces: [],
+7
View File
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
from __future__ import annotations
import pathlib
from contextvars import ContextVar
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(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
ASSET_VERSION = "dev"
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",
"info": {
"title": "FlowDeck",
"version": "7.5.0"
"version": "7.9.0"
},
"paths": {
"/auth/register": {
@@ -16725,6 +16725,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -16974,6 +16985,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17123,6 +17145,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17233,6 +17266,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17397,6 +17441,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17462,6 +17517,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17617,6 +17683,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17727,6 +17804,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17837,6 +17925,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18003,6 +18102,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18454,6 +18564,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18564,6 +18685,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18674,6 +18806,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18849,6 +18992,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18905,6 +19059,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19015,6 +19180,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19062,6 +19238,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19109,6 +19296,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19275,6 +19473,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19385,6 +19594,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19495,6 +19715,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19551,6 +19782,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20125,6 +20367,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20365,6 +20618,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20421,6 +20685,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20531,6 +20806,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20809,6 +21095,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21085,6 +21382,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21361,6 +21669,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21592,6 +21911,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21648,6 +21978,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21861,6 +22202,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22367,6 +22719,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22618,6 +22981,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22674,6 +23048,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22961,6 +23346,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
+107
View File
@@ -163,6 +163,113 @@ def test_gitea_cache_evicts_expired():
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
def test_migration_transaction_rolls_back():
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
ligne dans schema_version → la reprise rejoue proprement."""
import sqlite3 as _sqlite3
import pytest as _pytest
from app.migrations import _apply_one, _ensure_table
conn = _sqlite3.connect(":memory:")
_ensure_table(conn)
def boom(c):
c.execute("CREATE TABLE partial_x (id INTEGER)")
raise RuntimeError("boom")
with _pytest.raises(RuntimeError, match="boom"):
_apply_one(conn, 9999, "boom", boom)
assert (
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
is None
), "DDL partiel non annulé"
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
== 0
)
# chemin nominal : DDL + marque de version dans la même transaction
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
== 1
)
conn.close()
def test_columns_helper_validates_table_name():
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
import sqlite3 as _sqlite3
from app.migrations import columns
conn = _sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
assert columns(conn, "t1") == {"id", "nom"}
try:
columns(conn, "t1; DROP TABLE users")
raise AssertionError("identifiant non validé")
except ValueError:
pass
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():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app