Compare commits

..
10 Commits
Author SHA1 Message Date
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :

- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
  Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
  (bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
  401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
  depuis /users/me (le tout premier utilisateur d'un worker est admin :
  état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
  helper que le serveur), False avec signature bidon.

test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).

suite **1064/1064** · `ruff check app tests` OK · docs à jour
2026-10-01 13:09:32 -04:00
bruno b2e38aece7 test: A32 phase 2b — api.py 3 → 16/22 routes couvertes (v7.13.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
  relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès réseau réel)
- frontend-error(s) : capture, JSON invalide → ignored, DÉDUP d'une erreur
  répétée (count=2), lecture qui purge (cleared=true puis 0)
- checklist mutations : PATCH item (checked/content relus EN BASE), DELETE
  item, DELETE checklist (COUNT=0) — seed + cleanup en finally

Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.

suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
2026-10-01 12:35:22 -04:00
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
  couvertes) : les 5 listes en boucle (recents/favorites/published/private/
  workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
  avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
  non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
  renvoyait un 500 systématique (test vert → ASGI double response.start) et
  `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
  lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
  (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
  `library._format_size` devenu mort (la copie de dashboard.py est inchangée).
  `local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
  finally (pas de pollution des autres tests).

suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
  (secret piloté par monkeypatch, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
bruno 3a1276596c fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
  d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
  dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
  d'event, et la réponse n'est produite qu'une fois l'événement terminé
  (déterministe, équivalent sémantique de l'await). ponytail: les clients
  httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
  run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
  `_fire_event`, `fire_published`, `fire_unpublished` →
  `Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
  : wrapping par appariement de parenthèses chaîne-aware, assert de flip
  « plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
  réseau et restent async volontairement : import_csv_v2 (multipart),
  project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).

tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:35:43 -04:00
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
83 changed files with 1941 additions and 926 deletions
+226
View File
@@ -1,5 +1,231 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
relecture du roundtrip
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
suppression vérifiée
· `card` POST : 404 sans board, `{"status": "ok"}` avec
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
· checklist mutations : PATCH item (checked/content relus EN BASE),
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
stub de transport httpx. Reste global : `dashboard.py` 17/63,
`api_v2.py` 50/115
### Tests
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
### Tests
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
(recents/favorites/published/private/workspace) en un test de boucle,
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
(aucun appel réseau — la clé n'est qu'une string de workspace)
- Test de non-régression 404 sur les 2 routes supprimées
### Removed
- **2 routes cassées supprimées** (découverte des smokes) :
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
`library._format_size` devenu mort : supprimé aussi (une version vit dans
`dashboard.py`, inchangée)
- `local_workspace_items` : plus aucune occurrence dans `app/`
### Tests
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
### Tests
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'`await` d'avant). Note
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
### Tests
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
en 228 s
## 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) ## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed ### Changed
+5 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.5.0 7.15.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # 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.15.0 (audit — A32 phase 2d : dashboard +10 routes) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global ## Avancement Global
+7 -7
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.5.0", version="7.15.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json") @app.get("/manifest.json")
async def pwa_manifest(): def pwa_manifest():
"""Serve the static web manifest from disk (same URL as before v6.0.0).""" """Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json") return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js") @app.get("/sw.js")
async def service_worker(): def service_worker():
"""Serve the PWA service worker at top-level scope (/).""" """Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript") return FileResponse("static/sw.js", media_type="application/javascript")
@@ -282,7 +282,7 @@ async def service_worker():
@app.get("/api/csrf-token") @app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request): def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403.""" """Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
import secrets import secrets
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
@app.get("/api/pages") @app.get("/api/pages")
async def api_pages_alias(request: Request): def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency.""" """Alias /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
qs = str(request.url.query) qs = str(request.url.query)
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
@app.post("/api/pages") @app.post("/api/pages")
async def api_pages_post_alias(request: Request): def api_pages_post_alias(request: Request):
"""Alias POST /api/pages → /board/api/pages for API path consistency.""" """Alias POST /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/board/api/pages", status_code=307) return RedirectResponse(url="/board/api/pages", status_code=307)
@@ -341,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(_StarHTTPException) @app.exception_handler(_StarHTTPException)
async def http_exception_handler(request: Request, exc: _StarHTTPException): def http_exception_handler(request: Request, exc: _StarHTTPException):
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML. """Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
Registered on Starlette's HTTPException (the base class) so it catches both Registered on Starlette's HTTPException (the base class) so it catches both
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import secrets
import time import time
from collections import defaultdict from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ──────────────────────────────────────────────── # ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads # Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
""" """
CSP_HEADER = "Content-Security-Policy" CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = ( CSP_VALUE = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " # chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; " "font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; " "connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
) )
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request) response = await call_next(request)
# Only set CSP on HTML responses # Only set CSP on HTML responses
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
if "text/html" in content_type: if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response return response
+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: def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn) _ensure_table(conn)
row = conn.execute( row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS: for version, name, fn in MIGRATIONS:
if version <= applied: if version <= applied:
continue 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) fn(conn)
conn.execute( conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)", "INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name), (version, name),
) )
conn.commit() conn.commit()
applied = version except BaseException:
logger.info("Applied migration %d: %s", version, name) conn.rollback()
raise
return applied
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/ ``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron. 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: if "id" not in _pcols:
conn.execute( 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)" "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: if "id" not in _scols:
conn.execute( 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)" "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: if "id" not in _projcols:
conn.execute( 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)" "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: if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''") conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols: 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: def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config, """v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently).""" 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: if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'") 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: if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'") 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 ``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``. 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: if "group_name" not in _pcols:
conn.execute( conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''" "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: if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER") conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols: if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP") 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: if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''") 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)" "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: if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''") 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 ``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves). (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: if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0") conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols: 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"): 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: if "permission_type" not in cols:
conn.execute( conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'" 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: if "permission_type" not in _ccols:
conn.execute( conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'" "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: def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present.""" """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: if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1") 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. ``idempotency_keys`` — Idempotency-Key support for POST creations.
""" """
# api_tokens extra columns # 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: if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'") conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols: 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 New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt). ``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: if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''") conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols: 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 ``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``). 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: if "collection_row_id" not in cols:
conn.execute( conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER " "ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)" "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: if "form_config_json" not in cols:
conn.execute( conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'" "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: if "search_excluded" not in cols:
conn.execute( conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0" "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 " "CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)" "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: if "trigger_mode" not in auto_cols:
conn.execute( conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'" "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: if "button_automation_id" not in prop_cols:
conn.execute( conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id " "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 " "CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)" "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: if "external_event_id" not in cols:
conn.execute( conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''" "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 " "CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)" "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: if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''") conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols: 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"): 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: if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER") 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 ── # ── Users ──
@router.get("/users") @router.get("/users")
async def list_users(_admin=Depends(admin_required)): def list_users(_admin=Depends(admin_required)):
"""List all users with workspace/file/folder counts and storage usage.""" """List all users with workspace/file/folder counts and storage usage."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
@router.delete("/users/{user_id:int}") @router.delete("/users/{user_id:int}")
async def delete_user(user_id: int, _admin=Depends(admin_required)): def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data.""" """Delete a user and cascade their data."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
# ── Stats ── # ── Stats ──
@router.get("/stats") @router.get("/stats")
async def user_stats(_admin=Depends(admin_required)): def user_stats(_admin=Depends(admin_required)):
"""Aggregate stats: total users, workspaces, files, storage.""" """Aggregate stats: total users, workspaces, files, storage."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
# ── Audit ── # ── Audit ──
@router.get("/audit") @router.get("/audit")
async def audit_log(limit: int = 100, _admin=Depends(admin_required)): def audit_log(limit: int = 100, _admin=Depends(admin_required)):
"""Recent login history.""" """Recent login history."""
from app.db import get_conn from app.db import get_conn
with get_conn() as conn: with get_conn() as conn:
+10 -10
View File
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
@router.get("/conversations/{conversation_id}") @router.get("/conversations/{conversation_id}")
async def get_conversation(request: Request, conversation_id: int): def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone() conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv: if not conv:
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
@router.delete("/conversations/{conversation_id}") @router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: int): def delete_conversation(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone(): if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
raise HTTPException(status_code=404, detail="Conversation introuvable") raise HTTPException(status_code=404, detail="Conversation introuvable")
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
@router.get("/conversations/{conversation_id}/actions") @router.get("/conversations/{conversation_id}/actions")
async def list_actions(request: Request, conversation_id: int): def list_actions(request: Request, conversation_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id", "SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
@router.post("/actions/{action_id}/undo") @router.post("/actions/{action_id}/undo")
async def undo(request: Request, action_id: int): def undo(request: Request, action_id: int):
try: try:
undo_action(action_id) undo_action(action_id)
except ValueError as exc: except ValueError as exc:
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
@router.get("/skills/gallery") @router.get("/skills/gallery")
async def skills_gallery(request: Request): def skills_gallery(request: Request):
presets = skill_gallery.list_gallery() presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets), return {"gallery": presets, "total": len(presets),
"install": "POST /api/agent/skills/gallery/{slug}/install"} "install": "POST /api/agent/skills/gallery/{slug}/install"}
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
@router.get("/skills/{skill_id}/export") @router.get("/skills/{skill_id}/export")
async def export_skill(request: Request, skill_id: int): def export_skill(request: Request, skill_id: int):
"""Document JSON portable — à rejouer sur /api/agent/skills/import.""" """Document JSON portable — à rejouer sur /api/agent/skills/import."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
@router.delete("/skills/{skill_id}") @router.delete("/skills/{skill_id}")
async def delete_skill(request: Request, skill_id: int): def delete_skill(request: Request, skill_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row: if not row:
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
@router.get("/tools") @router.get("/tools")
async def list_tools(request: Request): def list_tools(request: Request):
registry = ToolRegistry() registry = ToolRegistry()
tools = registry.schema() tools = registry.schema()
return {"tools": tools} return {"tools": tools}
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
@router.get("/{agent_id}") @router.get("/{agent_id}")
async def get_agent(request: Request, agent_id: int): def get_agent(request: Request, agent_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row: if not row:
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
@router.delete("/{agent_id}") @router.delete("/{agent_id}")
async def delete_agent(request: Request, agent_id: int): def delete_agent(request: Request, agent_id: int):
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing: if not existing:
+14 -14
View File
@@ -83,7 +83,7 @@ async def health(request: Request):
@router.get("/stats") @router.get("/stats")
async def stats(): def stats():
"""Global stats for dashboard.""" """Global stats for dashboard."""
with get_conn() as conn: with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"] board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
@router.post("/col-mapping") @router.post("/col-mapping")
async def set_col_mapping( def set_col_mapping(
owner: str = Query(...), owner: str = Query(...),
repo: str = Query(...), repo: str = Query(...),
column: str = Query(...), column: str = Query(...),
@@ -209,7 +209,7 @@ async def set_col_mapping(
@router.delete("/col-mapping") @router.delete("/col-mapping")
async def delete_col_mapping( def delete_col_mapping(
owner: str = Query(...), owner: str = Query(...),
repo: str = Query(...), repo: str = Query(...),
column: str = Query(...), column: str = Query(...),
@@ -234,7 +234,7 @@ async def delete_col_mapping(
@router.get("/board-config/{owner}/{repo}") @router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str): def get_board_config(owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
board = conn.execute( board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?", "SELECT * FROM boards WHERE project_owner=? AND project_name=?",
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
@router.post("/board-config/{owner}/{repo}") @router.post("/board-config/{owner}/{repo}")
async def update_board_config( def update_board_config(
owner: str, owner: str,
repo: str, repo: str,
columns: str = Query(default=""), columns: str = Query(default=""),
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
# ── v0.5.0: Checklists ── # ── v0.5.0: Checklists ──
@router.post("/checklists/{owner}/{repo}/{issue_id}") @router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist( def create_checklist(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -484,7 +484,7 @@ async def create_checklist(
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}") @router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item( def add_checklist_item(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -502,7 +502,7 @@ async def add_checklist_item(
@router.patch("/checklist-items/{item_id}") @router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item( def toggle_checklist_item(
item_id: int, item_id: int,
checked: bool = Query(default=False), checked: bool = Query(default=False),
content: str = Query(default=""), content: str = Query(default=""),
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
@router.delete("/checklist-items/{item_id}") @router.delete("/checklist-items/{item_id}")
async def delete_checklist_item(item_id: int): def delete_checklist_item(item_id: int):
"""Delete a checklist item.""" """Delete a checklist item."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,)) conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
@router.delete("/checklists/{checklist_id}") @router.delete("/checklists/{checklist_id}")
async def delete_checklist(checklist_id: int): def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items.""" """Delete a checklist and all its items."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,)) conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
@@ -545,7 +545,7 @@ async def delete_checklist(checklist_id: int):
# ── v1.0.0: User management ── # ── v1.0.0: User management ──
@router.get("/users/me") @router.get("/users/me")
async def get_my_profile(request: Request): def get_my_profile(request: Request):
"""Get current user profile.""" """Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user: if not user:
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
@router.put("/users/me") @router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""), def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")): email: str = Query(default="")):
"""Update current user's local profile.""" """Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -584,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
# ── v0.5.0: Card priority & due date ── # ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}") @router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card( def update_card(
owner: str, owner: str,
repo: str, repo: str,
issue_id: int, issue_id: int,
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
@router.get("/frontend-errors") @router.get("/frontend-errors")
async def get_frontend_errors(request: Request, clear: bool = True): def get_frontend_errors(request: Request, clear: bool = True):
"""Retourne les erreurs frontend récentes. Hermes appelle cette route.""" """Retourne les erreurs frontend récentes. Hermes appelle cette route."""
errors = list(_frontend_errors) errors = list(_frontend_errors)
if clear: if clear:
+130 -317
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
# ── Agents ───────────────────────────────────────────────────────────────── # ── Agents ─────────────────────────────────────────────────────────────────
@router.get("/agents") @router.get("/agents")
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)): def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
ws = _workspace_of(request) ws = _workspace_of(request)
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
@router.get("/agents/{agent_id}") @router.get("/agents/{agent_id}")
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
@router.delete("/agents/{agent_id}") @router.delete("/agents/{agent_id}")
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone(): if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
# ── Conversations (static paths declared before /agents/{agent_id}) ──────── # ── Conversations (static paths declared before /agents/{agent_id}) ────────
@router.get("/agents/conversations") @router.get("/agents/conversations")
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)): def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
@router.get("/agents/conversations/{conversation_id}") @router.get("/agents/conversations/{conversation_id}")
async def get_conversation_v2(conversation_id: int, request: Request, def get_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
@router.delete("/agents/conversations/{conversation_id}") @router.delete("/agents/conversations/{conversation_id}")
async def delete_conversation_v2(conversation_id: int, request: Request, def delete_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
@router.get("/agents/conversations/{conversation_id}/actions") @router.get("/agents/conversations/{conversation_id}/actions")
async def list_actions_v2(conversation_id: int, request: Request, def list_actions_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization) user = _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
@router.post("/agents/actions/{action_id}/undo") @router.post("/agents/actions/{action_id}/undo")
async def undo_action_v2(action_id: int, request: Request, def undo_action_v2(action_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
# ── Skill marketplace ────────────────────────────────────────────────────── # ── Skill marketplace ──────────────────────────────────────────────────────
@router.get("/skills") @router.get("/skills")
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)): def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
ws = _workspace_of(request) ws = _workspace_of(request)
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
# Gallery & import are static segments: declared before /skills/{skill_id} so # Gallery & import are static segments: declared before /skills/{skill_id} so
# FastAPI never tries to coerce "gallery" into an int path parameter. # FastAPI never tries to coerce "gallery" into an int path parameter.
@router.get("/skills/gallery") @router.get("/skills/gallery")
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)): def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
presets = skill_gallery.list_gallery() presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets), return {"gallery": presets, "total": len(presets),
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
@router.get("/skills/{skill_id}") @router.get("/skills/{skill_id}")
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)): def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization) _guard(request, authorization)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
@router.get("/skills/{skill_id}/export") @router.get("/skills/{skill_id}/export")
async def export_skill_v2(skill_id: int, request: Request, def export_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere.""" """Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
_guard(request, authorization) _guard(request, authorization)
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
@router.delete("/skills/{skill_id}") @router.delete("/skills/{skill_id}")
async def delete_skill_v2(skill_id: int, request: Request, def delete_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True) user = _guard(request, authorization, write=True)
with get_conn() as conn: with get_conn() as conn:
+1 -1
View File
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
@router.get("/api/v2/audit/logs") @router.get("/api/v2/audit/logs")
async def audit_logs(request: Request): def audit_logs(request: Request):
_admin_user(request) _admin_user(request)
qp = request.query_params qp = request.query_params
source = (qp.get("source") or "all").lower() source = (qp.get("source") or "all").lower()
+19 -9
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.config import settings from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth") router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback" return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html> LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register") @router.get("/register")
async def register_page(request: Request): def register_page(request: Request):
"""Show the registration page (local login page with register tab active).""" """Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace( return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"', 'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"' 'class="tab" onclick="switchTab(\'login\')"'
).replace( ).replace(
@@ -163,16 +173,16 @@ async def register_page(request: Request):
).replace( ).replace(
'id="submit-btn">Login<', 'id="submit-btn">Login<',
'id="submit-btn">Register<' 'id="submit-btn">Register<'
), status_code=200) )), status_code=200)
@router.get("/login") @router.get("/login")
async def login(request: Request, provider: str = Query("gitea")): def login(request: Request, provider: str = Query("gitea")):
"""Redirect to OAuth2 authorize page or show local login page.""" """Redirect to OAuth2 authorize page or show local login page."""
# Local login page (POST handled by /auth/local-login) # Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
if provider == "local": if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200) return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured # OAuth flow — check if provider is configured
from app.auth.providers import get_provider from app.auth.providers import get_provider
@@ -450,7 +460,7 @@ async def callback(
@router.get("/logout") @router.get("/logout")
async def logout(request: Request): def logout(request: Request):
"""Clear session and redirect to login page. """Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one SAML sessions additionally hand over to the IdP's Single Logout when one
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
@router.get("/2fa/status") @router.get("/2fa/status")
async def twofa_status(request: Request): def twofa_status(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
return {"enabled": _2fa.is_enabled(user["id"]), return {"enabled": _2fa.is_enabled(user["id"]),
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
@router.post("/2fa/setup") @router.post("/2fa/setup")
async def twofa_setup(request: Request): def twofa_setup(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
return _2fa.setup_secret(user["id"]) return _2fa.setup_secret(user["id"])
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
@router.post("/2fa/disable") @router.post("/2fa/disable")
async def twofa_disable(request: Request): def twofa_disable(request: Request):
from app.services import two_factor as _2fa from app.services import two_factor as _2fa
user = _session_user_or_401(request) user = _session_user_or_401(request)
_2fa.disable(user["id"]) _2fa.disable(user["id"])
+6 -6
View File
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
@router.get("/workspace/automations") @router.get("/workspace/automations")
async def list_automations(request: Request): def list_automations(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall() rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows] items = [dict(r) for r in rows]
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
@router.get("/workspace/automations/{auto_id}") @router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int): def get_automation(request: Request, auto_id: int):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone() row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row: if not row:
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
@router.delete("/workspace/automations/{auto_id}") @router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int): def delete_automation(request: Request, auto_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,)) conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit() conn.commit()
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
@router.get("/workspace/automations/{auto_id}/runs") @router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50): def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=? """SELECT * FROM automation_runs WHERE automation_id=?
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
@router.get("/workspace/automations/{auto_id}/steps") @router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int): def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None: if _get_auto(auto_id) is None:
return _auto_404() return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)} return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
@router.delete("/workspace/automations/steps/{step_id}") @router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int): def delete_step(request: Request, step_id: int):
_require_session(request) _require_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,)) conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
+23 -23
View File
@@ -66,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
@router.get("/api/wiki/pages") @router.get("/api/wiki/pages")
async def wiki_page_search(request: Request, q: str = Query(default="")): def wiki_page_search(request: Request, q: str = Query(default="")):
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
every non-deleted page the current user can see (single source: pages).""" every non-deleted page the current user can see (single source: pages)."""
q = (q or "").strip().lower() q = (q or "").strip().lower()
@@ -98,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
@router.get("/api/wiki/titles") @router.get("/api/wiki/titles")
async def wiki_titles(request: Request, ids: str = Query(default="")): def wiki_titles(request: Request, ids: str = Query(default="")):
"""v5.11.0: resolve page-id lists to current labels (rename propagation).""" """v5.11.0: resolve page-id lists to current labels (rename propagation)."""
parsed: list[int] = [] parsed: list[int] = []
for part in (ids or "").split(","): for part in (ids or "").split(","):
@@ -179,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
@router.get("/api/page-templates") @router.get("/api/page-templates")
async def list_page_templates_api(request: Request): def list_page_templates_api(request: Request):
"""v5.12.0: built-in + user global page templates for the picker.""" """v5.12.0: built-in + user global page templates for the picker."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id") uid = (user or {}).get("id")
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
# ═══════════ Library page ═══════════ # ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.""" """Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
# ═══════════ Favorites API ═══════════ # ═══════════ Favorites API ═══════════
@router.get("/api/favorites") @router.get("/api/favorites")
async def list_favorites(request: Request): def list_favorites(request: Request):
"""List favorited page IDs for the current user.""" """List favorited page IDs for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1 uid = user["id"] if user and user.get("id") else 1
@@ -1033,7 +1033,7 @@ async def unpublish_page(request: Request, page_id: int):
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ # ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash") @router.get("/api/trash")
async def list_trash(request: Request): def list_trash(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
@@ -1052,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}") @router.delete("/api/trash/{page_id}")
async def permanent_delete(request: Request, page_id: int): def permanent_delete(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request): def trash_page(request: Request):
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
template = env.get_template("trash.html") template = env.get_template("trash.html")
@@ -1072,7 +1072,7 @@ async def trash_page(request: Request):
# These routes MUST be registered before the catch-all /{owner}/{repo} below. # These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks") @router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")): def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace.""" """List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks from app.services.synced_blocks import list_synced_blocks
@@ -1147,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}") @router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int): def get_synced_block_api(sid: int):
"""Get a synced block by id.""" """Get a synced block by id."""
from app.services.synced_blocks import get_synced_block from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid) sb = get_synced_block(sid)
@@ -1173,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/synced/{sid}") @router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int): def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync).""" """Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid) remove_page_synced(page_id, sid)
@@ -1181,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
@router.get("/api/pages/{page_id}/synced") @router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int): def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page.""" """Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)} return {"synced_blocks": get_page_synced(page_id)}
@@ -1190,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
# ═══════════ Board page ═══════════ # ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")): def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
sidebar = _sidebar_data(request, owner, repo) sidebar = _sidebar_data(request, owner, repo)
@@ -1279,12 +1279,12 @@ async def board_view(
# ═══════════ v0.9.0: Custom Properties API ═══════════ # ═══════════ v0.9.0: Custom Properties API ═══════════
@router.get("/api/properties/{owner}/{repo}") @router.get("/api/properties/{owner}/{repo}")
async def get_properties(owner: str, repo: str): def get_properties(owner: str, repo: str):
return {"properties": _get_project_properties(owner, repo)} return {"properties": _get_project_properties(owner, repo)}
@router.post("/api/properties/{owner}/{repo}") @router.post("/api/properties/{owner}/{repo}")
async def create_property(owner: str, repo: str, name: str = Query(...), def create_property(owner: str, repo: str, name: str = Query(...),
prop_type: str = Query(default="select"), prop_type: str = Query(default="select"),
options: str = Query(default="")): options: str = Query(default="")):
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()]) opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
@@ -1301,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
@router.delete("/api/properties/{owner}/{repo}") @router.delete("/api/properties/{owner}/{repo}")
async def delete_property(owner: str, repo: str, name: str = Query(...)): def delete_property(owner: str, repo: str, name: str = Query(...)):
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", "DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
@@ -1312,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
@router.post("/api/properties/{owner}/{repo}/values") @router.post("/api/properties/{owner}/{repo}/values")
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...), def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
name: str = Query(...), value: str = Query(default="")): name: str = Query(...), value: str = Query(default="")):
with get_conn() as conn: with get_conn() as conn:
prop = conn.execute( prop = conn.execute(
@@ -1332,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
# ═══════════ v0.9.0: AI Keywords API ═══════════ # ═══════════ v0.9.0: AI Keywords API ═══════════
@router.get("/api/ai-keywords/{owner}/{repo}") @router.get("/api/ai-keywords/{owner}/{repo}")
async def get_ai_keywords(owner: str, repo: str): def get_ai_keywords(owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30", "SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
@@ -1395,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
@router.get("/api/pages/{page_id}") @router.get("/api/pages/{page_id}")
async def get_page(request: Request, page_id: int): def get_page(request: Request, page_id: int):
"""Get a Markdown page.""" """Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"): if not user or not user.get("id"):
@@ -1543,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
@router.get("/api/pages/{page_id}/backlinks") @router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int): def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…"). """v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal Scans every non-deleted page's blocks (and raw markdown) for an internal
@@ -1591,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/versions") @router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int): def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page.""" """v5.4.0: version history for a block-editor page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/cover") @router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int): def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,)) conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit() conn.commit()
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
@router.get("/pages/{page_id}", response_class=HTMLResponse) @router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int): def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files. """Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
+2 -2
View File
@@ -49,7 +49,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page.""" """List page-level and inline comments for a FlowDeck page."""
_current_user(request) _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
@router.delete("/comments/{comment_id}") @router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int): def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies.""" """Delete a comment and its replies."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
+29 -28
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule, validate_rule,
) )
from app.services.reminders import REMINDER_KEY, parse_lead from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict: def _current_user(request: Request) -> dict:
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def list_collections(request: Request): def list_collections(request: Request):
"""Page listing all collections in the workspace.""" """Page listing all collections in the workspace."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
@router.get("/api") @router.get("/api")
async def list_collections_api(request: Request): def list_collections_api(request: Request):
"""API: list all collections.""" """API: list all collections."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
@router.get("/pages/{page_id}/api") @router.get("/pages/{page_id}/api")
async def get_page_api(request: Request, page_id: int): def get_page_api(request: Request, page_id: int):
"""API: get a single page.""" """API: get a single page."""
with get_conn() as conn: with get_conn() as conn:
page = conn.execute( page = conn.execute(
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
@router.get("/pages/{page_id}/open/api") @router.get("/pages/{page_id}/open/api")
async def open_row_page_api(request: Request, page_id: int): def open_row_page_api(request: Request, page_id: int):
"""v6.5.0 — content page of a database row (lazy-created). """v6.5.0 — content page of a database row (lazy-created).
Any DB view (table/board/gallery/list/calendar) opens a row through Any DB view (table/board/gallery/list/calendar) opens a row through
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
@router.get("/boards/api") @router.get("/boards/api")
async def list_boards_as_collections(request: Request): def list_boards_as_collections(request: Request):
"""API: list all Gitea boards as pseudo-collections.""" """API: list all Gitea boards as pseudo-collections."""
from app.services.collection_adapter import GiteaBoardCompat from app.services.collection_adapter import GiteaBoardCompat
boards = GiteaBoardCompat.list_boards_as_collections() boards = GiteaBoardCompat.list_boards_as_collections()
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
@router.get("/property-types/api") @router.get("/property-types/api")
async def list_property_types_api(request: Request): def list_property_types_api(request: Request):
"""API: list all available property types.""" """API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES} return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api") @router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int): def list_properties_api(request: Request, collection_id: int):
"""API: list all properties visible to the current user.""" """API: list all properties visible to the current user."""
user = _session_user(request) user = _session_user(request)
_require_view(collection_id, user) _require_view(collection_id, user)
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
@router.get("/{collection_id}/members/api") @router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int): def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property. """API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to Resolves the collection's workspace and returns its members (falling back to
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
@router.get("/{collection_id}/calendar/api") @router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int, def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "", start: str = "", end: str = "",
date_property: str = ""): date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end]. """API (v5.8.0): expanded calendar events for a window [start, end].
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
@router.get("/timezones/api") @router.get("/timezones/api")
async def timezones_api(request: Request): def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list.""" """API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
@router.delete("/properties/{prop_id}/api") @router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int): def delete_property_api(request: Request, prop_id: int):
"""API: delete a property.""" """API: delete a property."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
@router.get("/views/{view_id}/api") @router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int): def get_view_api(request: Request, view_id: int):
"""API: get a single view config.""" """API: get a single view config."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
@router.get("/{collection_id}/views/api") @router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int): def list_views_api(request: Request, collection_id: int):
"""API: list views for a collection visible to the current user. """API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone; Shared/legacy views (``created_by IS NULL``) are visible to everyone;
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
@router.delete("/views/{view_id}/api") @router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int): def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view.""" """API: delete a saved view."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
@router.get("/{collection_id}/pages/{page_id}/sub-items") @router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int): def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page.""" """API: list sub-items of a page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/pages/{page_id}/status-aggregate") @router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int): def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children.""" """API: compute aggregate status from children."""
with get_conn() as conn: with get_conn() as conn:
children = conn.execute( children = conn.execute(
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
@router.get("/{collection_id}/sources/api") @router.get("/{collection_id}/sources/api")
async def list_data_sources(request: Request, collection_id: int): def list_data_sources(request: Request, collection_id: int):
"""API: list all data sources for a collection.""" """API: list all data sources for a collection."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
@router.delete("/{collection_id}/sources/{source_id}/api") @router.delete("/{collection_id}/sources/{source_id}/api")
async def remove_data_source(request: Request, collection_id: int, source_id: int): def remove_data_source(request: Request, collection_id: int, source_id: int):
"""API: remove a data source from a collection.""" """API: remove a data source from a collection."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
@router.post("/{collection_id}/toggle-inline/api") @router.post("/{collection_id}/toggle-inline/api")
async def toggle_inline(request: Request, collection_id: int): def toggle_inline(request: Request, collection_id: int):
"""API: toggle a collection between full-page and inline mode.""" """API: toggle a collection between full-page and inline mode."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute( coll = conn.execute(
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
@router.put("/{collection_id}/toggle-task/api") @router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int): def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks).""" """API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
@router.get("/{collection_id}/pages/{page_id}/dependencies/api") @router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int): def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related).""" """API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1819,7 +1820,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api") @router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int): def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency.""" """API: remove a dependency."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse) @router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int): def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
"""v7.3.0: render a collection dashboard grid (multi-DB widgets). """v7.3.0: render a collection dashboard grid (multi-DB widgets).
Widgets live in ``collection_dashboards.layout_json`` as Widgets live in ``collection_dashboards.layout_json`` as
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
@router.get("/{collection_id}", response_class=HTMLResponse) @router.get("/{collection_id}", response_class=HTMLResponse)
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse) @router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"): def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type.""" """Main view — renders collection in the requested view type."""
# v6.0.0: granular collection permissions — hide restricted collections. # v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request)) _require_view(collection_id, _session_user(request))
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style> </style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div> <div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script> <script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{ new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}', type: '{chart_type}',
data: {{ data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form> </form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div> <div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div> </div>
<script> <script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{ async function submitForm(e) {{
e.preventDefault(); e.preventDefault();
const form = document.getElementById('collectionForm'); const form = document.getElementById('collectionForm');
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script> <script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6); const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map); L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json}; const markers = {markers_json};
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api") @router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int): def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages.""" """API: get a single collection with its pages."""
# v6.0.0: granular collection permissions — hide restricted collections. # v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request)) _require_view(collection_id, _session_user(request))
+40 -40
View File
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse) @router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided.""" """Trash page — scoped to workspace if owner/repo provided."""
from app.routers.board import _sidebar_data as board_sidebar from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV from app.templating import ENV
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse) @router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace). """Library page — tabbed view (recents, favorites, shared, published, private, workspace).
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints. Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse) @router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int): def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer. """Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header).""" ?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
@@ -434,7 +434,7 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse) @router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request): def accounts_page(request: Request):
"""Account management panel.""" """Account management panel."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -451,7 +451,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse) @router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request): def help_page(request: Request):
"""Comprehensive help & documentation page.""" """Comprehensive help & documentation page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -656,7 +656,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse) @router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request): def settings_page(request: Request):
"""User settings page — profile, forges, tokens.""" """User settings page — profile, forges, tokens."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -731,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token") @router.post("/api/user/token")
async def generate_token(request: Request): def generate_token(request: Request):
import secrets import secrets
uid = _require_user_id(request) uid = _require_user_id(request)
token = secrets.token_hex(32) token = secrets.token_hex(32)
@@ -745,7 +745,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}") @router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str): def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request) uid = _require_user_id(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -834,7 +834,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════ # ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse) @router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request): def workspace_page(request: Request):
"""Unified workspace showing all projects.""" """Unified workspace showing all projects."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -851,7 +851,7 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse) @router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request): def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files.""" """Gitea workspace — browse repo files."""
import json import json
@@ -971,7 +971,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════ # ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members") @router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int): def list_members(request: Request, ws_id: int):
"""List all members of a workspace.""" """List all members of a workspace."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1022,7 +1022,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}") @router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int): def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace.""" """Remove a member from a workspace."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -1036,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════ # ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse) @router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None): def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree. """Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?folder=ID is provided, shows that folder's contents with breadcrumb.
@@ -1084,7 +1084,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree") @router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None): def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace. """Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children. If ?folder=ID is provided, returns only that folder's children.
@@ -1109,7 +1109,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
@router.get("/api/local-workspace/page-content/{page_id:int}") @router.get("/api/local-workspace/page-content/{page_id:int}")
async def get_page_content(page_id: int): def get_page_content(page_id: int):
"""Return the raw content of a page (for preview).""" """Return the raw content of a page (for preview)."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -1174,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@router.get("/api/pages/{page_id}/download") @router.get("/api/pages/{page_id}/download")
async def download_page_file(request: Request, page_id: int): def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment).""" """Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id) _require_page_view(request, page_id)
with get_conn() as conn: with get_conn() as conn:
@@ -1197,7 +1197,7 @@ async def download_page_file(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/file-content") @router.get("/api/pages/{page_id}/file-content")
async def page_file_content(request: Request, page_id: int): def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard). """Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
@@ -1225,7 +1225,7 @@ async def page_file_content(request: Request, page_id: int):
@router.get("/api/local-workspace/breadcrumb") @router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int): def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder.""" """Return breadcrumb trail for a folder."""
with get_conn() as conn: with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder) breadcrumb = _build_breadcrumb(conn, folder)
@@ -1393,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@router.get("/api/nav/menu") @router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None): def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu. """Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the If ``parent_id`` is given, returns that page's children; otherwise the
@@ -1483,7 +1483,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}") @router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int): def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at).""" """Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime from datetime import datetime
with get_conn() as conn: with get_conn() as conn:
@@ -1496,7 +1496,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore") @router.post("/api/local-workspace/items/{item_id:int}/restore")
async def restore_local_workspace_item(request: Request, item_id: int): def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder.""" """Restore a soft-deleted file/folder."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -1508,7 +1508,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}") @router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str): def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk.""" """Serve an uploaded file from disk."""
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
@@ -1774,7 +1774,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse) @router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request): def workspaces_page(request: Request):
"""Workspaces list page.""" """Workspaces list page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces") @router.get("/api/workspaces")
async def list_workspaces(request: Request): def list_workspaces(request: Request):
"""List all workspaces for the current user.""" """List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1 uid = user["id"] if user and user.get("id") else 1
@@ -1855,7 +1855,7 @@ async def rename_workspace(request: Request, ws_id: int):
@router.delete("/api/workspaces/{ws_id:int}") @router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int): def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages.""" """Delete a workspace and all its pages."""
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
@@ -1866,7 +1866,7 @@ async def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select") @router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int): def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie.""" """Set the active workspace via cookie."""
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id}) response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════ # ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse) @router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request): def app_settings_page(request: Request):
"""Settings & configuration page.""" """Settings & configuration page."""
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}") @router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str): def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file.""" """Serve an uploaded avatar image file."""
from pathlib import Path from pathlib import Path
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
@router.get("/api/avatar/{user_id:int}") @router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int): def get_avatar(user_id: int):
"""Redirect to the user's avatar.""" """Redirect to the user's avatar."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone() row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -2002,7 +2002,7 @@ async def update_tag_global(tag_id: int, request: Request):
@router.delete("/api/settings/tags/{tag_id:int}") @router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int, request: Request): def delete_tag_global(tag_id: int, request: Request):
"""Delete a tag — only if owned by user.""" """Delete a tag — only if owned by user."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -2013,7 +2013,7 @@ async def delete_tag_global(tag_id: int, request: Request):
@router.get("/api/settings/tags/all") @router.get("/api/settings/tags/all")
async def list_all_tags_global(request: Request): def list_all_tags_global(request: Request):
"""List current user's tags with counts.""" """List current user's tags with counts."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════ # ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags") @router.get("/api/local-workspace/tags")
async def list_tags(request: Request): def list_tags(request: Request):
"""List ALL user tags with counts scoped to the active workspace.""" """List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
@@ -2055,7 +2055,7 @@ async def list_tags(request: Request):
@router.get("/api/local-workspace/items/{item_id:int}/tags") @router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int): def get_item_tags(item_id: int):
"""Get tags for a specific item.""" """Get tags for a specific item."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -2104,7 +2104,7 @@ async def add_item_tag(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}") @router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int): def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item.""" """Remove a tag from an item."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search") @router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""): def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated).""" """Search items by tags (comma-separated)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request)) ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None ws_id = ws["id"] if ws else None
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════ # ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree") @router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request): def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment. """Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations Called by appState().refreshSidebarTree() after CRUD operations
@@ -2260,7 +2260,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse) @router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str): def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required.""" """Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV from app.templating import ENV
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════ # ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content") @router.get("/api/pages/{page_id:int}/content")
async def api_page_content(page_id: int): def api_page_content(page_id: int):
"""Get page content for side peek preview.""" """Get page content for side peek preview."""
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -2563,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
@router.post("/api/pages/{page_id:int}/trash") @router.post("/api/pages/{page_id:int}/trash")
async def api_trash_page(page_id: int): def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash).""" """Soft-delete a page (move to trash)."""
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -2641,7 +2641,7 @@ async def api_convert_to_database(page_id: int, request: Request):
@router.get("/api/collections/{collection_id:int}/table-data") @router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int): def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view.""" """Get collection properties + pages for rendering the table view."""
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute( coll = conn.execute(
+2 -2
View File
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
@router.get("/api/custom-emojis") @router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request): def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis.""" """List the current workspace's custom emojis."""
ws_id = _active_ws(request) ws_id = _active_ws(request)
with get_conn() as conn: with get_conn() as conn:
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
@router.delete("/api/custom-emojis/{emoji_id}") @router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int): def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file).""" """Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request) ws_id = _active_ws(request)
with get_conn() as conn: with get_conn() as conn:
+4 -4
View File
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}") @router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request): def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
md = page_to_markdown(page) md = page_to_markdown(page)
filename = _safe_filename(page, "md") filename = _safe_filename(page, "md")
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}") @router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request): def export_html(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page) html = page_to_standalone_html(page)
filename = _safe_filename(page, "html") filename = _safe_filename(page, "html")
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}") @router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request): def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
try: try:
pdf_bytes = page_to_pdf_bytes(page) pdf_bytes = page_to_pdf_bytes(page)
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}") @router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request): def export_site(page_id: int, request: Request):
page = _load_page_or_404(request, page_id) page = _load_page_or_404(request, page_id)
site_bytes = build_static_site_bytes(page) site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site" title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
+5 -5
View File
@@ -162,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
# ── Account linking ── # ── Account linking ──
@router.get("/status") @router.get("/status")
async def gitea_status(request: Request): def gitea_status(request: Request):
"""Check if the current user has Gitea linked.""" """Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request) client = get_user_gitea_client(request)
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
@router.delete("/disconnect") @router.delete("/disconnect")
async def disconnect_gitea(request: Request): def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user.""" """Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -186,7 +186,7 @@ async def disconnect_gitea(request: Request):
# ── Private Pages (local FlowDeck pages linked to Gitea project) ── # ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages") @router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request): def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project.""" """List private pages for this Gitea project."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -225,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}") @router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request): def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page.""" """Get a single private page."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -272,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}") @router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request): def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page.""" """Delete a private page."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
+2 -2
View File
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status") @router.get("/status")
async def github_status(request: Request): def github_status(request: Request):
"""Check if the current user has GitHub linked.""" """Check if the current user has GitHub linked."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
@@ -22,7 +22,7 @@ async def github_status(request: Request):
@router.delete("/disconnect") @router.delete("/disconnect")
async def disconnect_github(request: Request): def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user.""" """Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager from app.auth.session import SessionManager
from app.db import get_conn from app.db import get_conn
+2 -2
View File
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
@router.get("/api/v2/agent-policies") @router.get("/api/v2/agent-policies")
async def list_policies(request: Request): def list_policies(request: Request):
_owner_or_admin(request) _owner_or_admin(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall() rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
@router.get("/api/v2/agent-approvals") @router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request): def list_approvals(request: Request):
_owner_or_admin(request) _owner_or_admin(request)
status = request.query_params.get("status", "pending") status = request.query_params.get("status", "pending")
with get_conn() as conn: with get_conn() as conn:
+5 -5
View File
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
@router.get("/sources") @router.get("/sources")
async def import_sources(request: Request): def import_sources(request: Request):
"""List every available importer for the UI source picker.""" """List every available importer for the UI source picker."""
return {"sources": list_sources()} return {"sources": list_sources()}
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
@router.post("/relations/resolve") @router.post("/relations/resolve")
async def import_resolve_relations(request: Request): def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props.""" """Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request) ws_id, _ = _workspace(request)
with get_conn() as conn: with get_conn() as conn:
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
@router.get("/jobs") @router.get("/jobs")
async def import_jobs(request: Request): def import_jobs(request: Request):
return {"jobs": list_jobs()} return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}") @router.get("/jobs/{job_id}")
async def import_job(job_id: str): def import_job(job_id: str):
job = get_job(job_id) job = get_job(job_id)
if not job: if not job:
raise HTTPException(404, "Job not found") raise HTTPException(404, "Job not found")
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
@router.get("/jobs/{job_id}/report") @router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str): def import_job_report(job_id: str):
"""Download a job's import report as JSON.""" """Download a job's import report as JSON."""
job = get_job(job_id) job = get_job(job_id)
if not job: if not job:
+8 -161
View File
@@ -157,7 +157,7 @@ BASE_SELECT = (
@router.get("/recents") @router.get("/recents")
async def library_recents( def library_recents(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -191,7 +191,7 @@ async def library_recents(
@router.get("/favorites") @router.get("/favorites")
async def library_favorites( def library_favorites(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -221,7 +221,7 @@ async def library_favorites(
@router.get("/shared") @router.get("/shared")
async def library_shared( def library_shared(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -292,7 +292,7 @@ async def library_shared(
@router.get("/published") @router.get("/published")
async def library_published( def library_published(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -317,7 +317,7 @@ async def library_published(
@router.get("/private") @router.get("/private")
async def library_private( def library_private(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
@@ -341,76 +341,8 @@ async def library_private(
return {"items": items} return {"items": items}
@router.get("/local-workspace-children/{item_id:int}")
async 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:
# Get the item to find its workspace
item = conn.execute(
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
[item_id],
).fetchone()
if not item:
return {"items": []}
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE parent_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[item_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": "",
"workspace": "",
"workspace_name": "",
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
@router.get("/children/{page_id:int}") @router.get("/children/{page_id:int}")
async def library_children(page_id: int, request: Request): def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library).""" """Return child pages for a given parent page (for tree expansion in Library)."""
uid = _get_user_id(request) uid = _get_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
@router.get("/repository") @router.get("/repository")
async def library_repository( def library_repository(
request: Request, request: Request,
gitea_owner: str = Query(default=""), gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""), gitea_repo: str = Query(default=""),
@@ -449,93 +381,8 @@ async def library_repository(
return {"items": items} return {"items": items}
@router.get("/local-workspace")
async def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
"""Return local workspace items (files/folders) formatted for Library display."""
from app.routers.dashboard import _get_active_workspace
uid = _get_user_id(request)
# Get the active workspace
ws = _get_active_workspace(request, user_id=uid)
if not ws:
return {"items": []}
ws_id = workspace_id or ws["id"]
# Query local workspace tree
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[ws_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": ws.get("name", "Workspace"),
"workspace": ws.get("name", ""),
"workspace_name": ws.get("name", ""),
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
@router.get("/workspace") @router.get("/workspace")
async def library_workspace( def library_workspace(
request: Request, request: Request,
source_type: str = Query(default="all"), source_type: str = Query(default="all"),
tree: int = Query(default=0), tree: int = Query(default=0),
+3 -3
View File
@@ -75,13 +75,13 @@ async def create_link(request: Request):
@router.get("/api/v2/calendar-links") @router.get("/api/v2/calendar-links")
async def get_links(request: Request): def get_links(request: Request):
user = _auth_user(request) user = _auth_user(request)
return {"links": cal.list_links(user["id"])} return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}") @router.delete("/api/v2/calendar-links/{link_id}")
async def remove_link(link_id: int, request: Request): def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id): if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found") raise HTTPException(404, "Link not found")
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
# ── free/busy ────────────────────────────────────────────────────────────── # ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy") @router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request): def freebusy(collection_id: int, request: Request):
_auth_user(request) _auth_user(request)
qp = request.query_params qp = request.query_params
try: try:
+2 -2
View File
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7): def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections.""" """My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request) user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin" user_login = user.get("login", "admin") if user else "admin"
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
@router.get("/api") @router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7): def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON.""" """API: return my tasks as JSON."""
user = _get_current_user(request) user = _get_current_user(request)
user.get("login", "admin") if user else "admin" user.get("login", "admin") if user else "admin"
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
@router.get("/{owner}/{repo}", response_class=HTMLResponse) @router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def get_notes(request: Request, owner: str, repo: str): def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'", "SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
+5 -5
View File
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
@router.get("") @router.get("")
async def list_notifications(request: Request, limit: int = 50): def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first.""" """List the current user's notifications, newest first."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
@router.get("/unread-count") @router.get("/unread-count")
async def unread_count(request: Request): def unread_count(request: Request):
"""Unread count for the topbar badge.""" """Unread count for the topbar badge."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
@router.get("/prefs") @router.get("/prefs")
async def get_prefs(request: Request): def get_prefs(request: Request):
"""Return the current user's notification email preferences.""" """Return the current user's notification email preferences."""
user = _current_user(request) user = _current_user(request)
from app.services import notifications as notif from app.services import notifications as notif
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
@router.get("/timezone") @router.get("/timezone")
async def get_timezone(request: Request): def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC).""" """Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request) user = _current_user(request)
with get_conn() as conn: with get_conn() as conn:
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
@router.get("/users/search") @router.get("/users/search")
async def search_users(request: Request, q: str = ""): def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions.""" """User autocomplete for @mentions."""
_current_user(request) _current_user(request)
q = (q or "").strip() q = (q or "").strip()
+1 -1
View File
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
@router.get("/welcome", response_class=HTMLResponse) @router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request): def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who """Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app.""" already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+14 -14
View File
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
@router.get("/pages/{page_id}/permissions") @router.get("/pages/{page_id}/permissions")
async def list_page_permissions(page_id: int, request: Request): def list_page_permissions(page_id: int, request: Request):
"""List explicit page grants + the caller's effective role.""" """List explicit page grants + the caller's effective role."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_page(page_id): if not pm.can_view_page(page_id):
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
@router.get("/pages/{page_id}/permissions/mine") @router.get("/pages/{page_id}/permissions/mine")
async def my_page_permission(page_id: int, request: Request): def my_page_permission(page_id: int, request: Request):
"""Effective role of the current user on a page (UI gating).""" """Effective role of the current user on a page (UI gating)."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_page(page_id): if not pm.can_view_page(page_id):
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
@router.delete("/pages/{page_id}/permissions/{perm_id}") @router.delete("/pages/{page_id}/permissions/{perm_id}")
async def revoke_page_permission(page_id: int, perm_id: int, request: Request): def revoke_page_permission(page_id: int, perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_page_permissions(page_id): if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions") raise HTTPException(403, "Only a page owner can manage its permissions")
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
@router.get("/collections/{collection_id}/permissions") @router.get("/collections/{collection_id}/permissions")
async def list_collection_permissions(collection_id: int, request: Request): def list_collection_permissions(collection_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found") raise HTTPException(404, "Collection not found")
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
@router.delete("/collections/{collection_id}/permissions/{perm_id}") @router.delete("/collections/{collection_id}/permissions/{perm_id}")
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request): def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id): if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage its permissions") raise HTTPException(403, "Only a collection owner can manage its permissions")
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/visible") @router.get("/collections/{collection_id}/properties/visible")
async def visible_properties(collection_id: int, request: Request): def visible_properties(collection_id: int, request: Request):
"""Split property ids into visible / hidden for the current user.""" """Split property ids into visible / hidden for the current user."""
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/{property_id}/permissions") @router.get("/collections/{collection_id}/properties/{property_id}/permissions")
async def list_property_permissions(collection_id: int, property_id: int, request: Request): def list_property_permissions(collection_id: int, property_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found") raise HTTPException(404, "Collection not found")
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}") @router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
async def revoke_property_permission(collection_id: int, property_id: int, def revoke_property_permission(collection_id: int, property_id: int,
perm_id: int, request: Request): perm_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id): if not pm.can_manage_collection_permissions(collection_id):
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
@router.get("/groups") @router.get("/groups")
async def list_groups(request: Request, workspace_id: int | None = None): def list_groups(request: Request, workspace_id: int | None = None):
user = _require_user(request) user = _require_user(request)
pm = PermissionManager(user["id"]) pm = PermissionManager(user["id"])
return {"groups": pm.get_groups_for_workspace(workspace_id)} return {"groups": pm.get_groups_for_workspace(workspace_id)}
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
@router.delete("/groups/{group_id}") @router.delete("/groups/{group_id}")
async def delete_group(group_id: int, request: Request): def delete_group(group_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
@router.get("/groups/{group_id}/members") @router.get("/groups/{group_id}/members")
async def list_group_members(group_id: int, request: Request): def list_group_members(group_id: int, request: Request):
user = _require_user(request) user = _require_user(request)
return {"members": PermissionManager(user["id"]).get_group_members(group_id)} return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
@router.delete("/groups/{group_id}/members/{user_id}") @router.delete("/groups/{group_id}/members/{user_id}")
async def remove_group_member(group_id: int, user_id: int, request: Request): def remove_group_member(group_id: int, user_id: int, request: Request):
pm = _pm(request) pm = _pm(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
@router.get("/users") @router.get("/users")
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""): def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
"""Workspace members (+ admins) for the grant pickers.""" """Workspace members (+ admins) for the grant pickers."""
_require_user(request) _require_user(request)
q = (q or "").strip().lower() q = (q or "").strip().lower()
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
@router.get("/audit/permissions") @router.get("/audit/permissions")
async def permission_audit(request: Request, limit: int = 100): def permission_audit(request: Request, limit: int = 100):
"""Full permission change history — workspace owner/admin only.""" """Full permission change history — workspace owner/admin only."""
user = _require_user(request) user = _require_user(request)
uid = user["id"] uid = user["id"]
+1 -1
View File
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
@router.get("") @router.get("")
async def list_projects(request: Request): def list_projects(request: Request):
"""List all synced projects (optionally filtered by type).""" """List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)} return {"projects": projects_svc.list_projects(proj_type)}
+6 -6
View File
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token") @router.post("/token")
async def generate_token(request: Request): def generate_token(request: Request):
"""Generate a public API access token (A4 : session obligatoire — plus de """Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous).""" « legacy shared token » `user_id=0` créable par un anonymous)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
@router.get("/collections", dependencies=[Depends(verify_token)]) @router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request): def public_list_collections(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall() rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]} return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)]) @router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int): def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll: if not coll:
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)]) @router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int): def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position", "SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)]) @router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int): def public_get_page(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p: if not p:
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
@router.get("/my-tasks", dependencies=[Depends(verify_token)]) @router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request): def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token).""" """Public API: list tasks (requires valid token)."""
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
@router.get("/api/realtime/stats") @router.get("/api/realtime/stats")
async def realtime_stats(request: Request): def realtime_stats(request: Request):
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits. """Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
Réservé aux utilisateurs authentifiés (données d'activité internes). Réservé aux utilisateurs authentifiés (données d'activité internes).
+7 -7
View File
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
# ── SCIM resources ───────────────────────────────────────────────────────── # ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users") @router.get("/scim/v2/Users")
async def scim_list(request: Request): def scim_list(request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall() rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
@router.get("/scim/v2/Users/{user_id}") @router.get("/scim/v2/Users/{user_id}")
async def scim_get(user_id: str, request: Request): def scim_get(user_id: str, request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
@router.delete("/scim/v2/Users/{user_id}") @router.delete("/scim/v2/Users/{user_id}")
async def scim_delete(user_id: str, request: Request): def scim_delete(user_id: str, request: Request):
_scim_guard(request) _scim_guard(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
@router.get("/api/v2/scim/tokens") @router.get("/api/v2/scim/tokens")
async def list_scim_tokens(request: Request): def list_scim_tokens(request: Request):
_admin_session(request) _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, created_by, revoked, created_at" rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
@router.delete("/api/v2/scim/tokens/{token_id}") @router.delete("/api/v2/scim/tokens/{token_id}")
async def revoke_scim_token(token_id: int, request: Request): def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,)) conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
# ── domain claims ────────────────────────────────────────────────────────── # ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims") @router.get("/api/v2/domain-claims")
async def list_domains(request: Request): def list_domains(request: Request):
_admin_session(request) _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall() rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
@router.delete("/api/v2/domain-claims/{domain_id}") @router.delete("/api/v2/domain-claims/{domain_id}")
async def delete_domain(domain_id: int, request: Request): def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request) admin = _admin_session(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,)) conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
+1 -1
View File
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
@router.get("/api/search") @router.get("/api/search")
async def search(request: Request, q: str = Query(default="")): def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None user_id = user.get("id") if user and user.get("id") else None
+2 -2
View File
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
@router.get("/api/v2/search/hybrid") @router.get("/api/v2/search/hybrid")
async def hybrid(request: Request): def hybrid(request: Request):
user = _auth_user(request) user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip() q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q: if not q:
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
@router.get("/api/v2/search/index-status") @router.get("/api/v2/search/index-status")
async def index_status(request: Request): def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility).""" """How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
+4 -4
View File
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
@router.get("/tokens") @router.get("/tokens")
async def list_tokens(request: Request): def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets).""" """List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -63,7 +63,7 @@ async def create_token(request: Request):
@router.delete("/tokens/{token_id:int}") @router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request): def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete).""" """Revoke an API token (soft delete)."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
@router.get("/sessions") @router.get("/sessions")
async def list_sessions(request: Request): def list_sessions(request: Request):
"""List the current user's active sessions with their devices.""" """List the current user's active sessions with their devices."""
uid = _current_user_id(request) uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", "")) current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
@router.post("/sessions/{sid}/revoke") @router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request): def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out.""" """Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request) uid = _current_user_id(request)
with get_conn() as conn: with get_conn() as conn:
+2 -2
View File
@@ -204,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
@router.delete("/pages/{page_id}/share/{share_id}") @router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request): def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation.""" """Remove a share invitation."""
_require_auth(request) _require_auth(request)
@@ -238,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
@router.get("/pages/{page_id}/shares") @router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request): def list_shares(page_id: int, request: Request):
"""Get all shares for a page.""" """Get all shares for a page."""
_require_auth(request) _require_auth(request)
+1 -1
View File
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
@router.get("/config") @router.get("/config")
async def get_sidebar_config(request: Request): def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config.""" """Get the current user's sidebar customization config."""
user = _get_user(request) user = _get_user(request)
with get_conn() as conn: with get_conn() as conn:
+11 -11
View File
@@ -315,7 +315,7 @@ async def create_site(request: Request):
@router.get("/api/v2/sites") @router.get("/api/v2/sites")
async def list_sites(request: Request): def list_sites(request: Request):
user = _auth_user(request) user = _auth_user(request)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
@router.get("/api/v2/sites/{site_id}") @router.get("/api/v2/sites/{site_id}")
async def get_site(site_id: int, request: Request): def get_site(site_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}") @router.delete("/api/v2/sites/{site_id}")
async def delete_site(site_id: int, request: Request): def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/pages") @router.get("/api/v2/sites/{site_id}/pages")
async def list_site_pages(site_id: int, request: Request): def list_site_pages(site_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}") @router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async def remove_site_page(site_id: int, page_id: int, request: Request): def remove_site_page(site_id: int, page_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/stats") @router.get("/api/v2/sites/{site_id}/stats")
async def site_stats(site_id: int, request: Request, days: int = 30): def site_stats(site_id: int, request: Request, days: int = 30):
user = _auth_user(request) user = _auth_user(request)
days = max(1, min(int(days or 30), 365)) days = max(1, min(int(days or 30), 365))
with get_conn() as conn: with get_conn() as conn:
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
@router.get("/s/{slug}", response_class=HTMLResponse) @router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str): def public_site_home(request: Request, slug: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug, site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", "")) host=request.headers.get("host", ""))
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse) @router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async def site_sitemap(request: Request, slug: str): def site_sitemap(request: Request, slug: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug) site = _resolve_site(conn, slug=slug)
if not site or _site_expired(site) or site.get("password_hash"): if not site or _site_expired(site) or site.get("password_hash"):
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse) @router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async def public_site_page(request: Request, slug: str, page_ref: str): def public_site_page(request: Request, slug: str, page_ref: str):
with get_conn() as conn: with get_conn() as conn:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", "")) site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site: if not site:
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
# ── Public Forms ─────────────────────────────────────────────────────────── # ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form") @router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request): def get_form_config(collection_id: int, request: Request):
_auth_user(request) _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
info = _form_config(conn, collection_id) info = _form_config(conn, collection_id)
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
@router.get("/f/{token}", response_class=HTMLResponse) @router.get("/f/{token}", response_class=HTMLResponse)
async def public_form(request: Request, token: str): def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM collections").fetchone() row = conn.execute("SELECT * FROM collections").fetchone()
+3 -3
View File
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
@router.get("/auth/saml/login") @router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT): def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP.""" """SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"): if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429) return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
@router.get("/auth/saml/metadata") @router.get("/auth/saml/metadata")
async def saml_metadata(request: Request): def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…).""" """SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error() cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml": if not cfg or cfg["provider_type"] != "saml":
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
@router.get("/api/v2/sso/providers") @router.get("/api/v2/sso/providers")
async def sso_providers(request: Request): def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag).""" """Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error() cfg = _sso_config_or_error()
if not cfg: if not cfg:
+1 -1
View File
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
@router.get("/status") @router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None), def sync_status(request: Request, workspace_id: int = Query(default=None),
authorization: str | None = Header(default=None)): authorization: str | None = Header(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync).""" """Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request, authorization, required_scope="read") user = _user(request, authorization, required_scope="read")
+1 -1
View File
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
# ── HTML: /extensions download page ── # ── HTML: /extensions download page ──
@router.get("/extensions", response_class=HTMLResponse) @router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request): def extensions_page(request: Request):
from app.routers.dashboard import _sidebar_data from app.routers.dashboard import _sidebar_data
from app.templating import ENV from app.templating import ENV
+3 -3
View File
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
@router.post("/register/begin") @router.post("/register/begin")
async def register_begin(request: Request): def register_begin(request: Request):
if not _require_lib(): if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed") raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json from webauthn import generate_registration_options, options_to_json
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
@router.get("/keys") @router.get("/keys")
async def list_keys(request: Request): def list_keys(request: Request):
user = _session_user(request) user = _session_user(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials" rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
@router.delete("/keys/{key_id}") @router.delete("/keys/{key_id}")
async def delete_key(key_id: int, request: Request): def delete_key(key_id: int, request: Request):
user = _session_user(request) user = _session_user(request)
with get_conn() as conn: with get_conn() as conn:
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?", cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
+17 -17
View File
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
# ── teamspaces ───────────────────────────────────────────────────────────── # ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces") @router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request): def list_teamspaces(request: Request):
user = _user(request) user = _user(request)
wid = request.query_params.get("workspace_id") wid = request.query_params.get("workspace_id")
if wid: if wid:
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse) @router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async def teamspace_page(teamspace_id: int, request: Request): def teamspace_page(teamspace_id: int, request: Request):
"""Teamspace detail HTML page — sidebar entry point.""" """Teamspace detail HTML page — sidebar entry point."""
user = _user(request) user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"]) ts = _teamspace_or_404(teamspace_id, user["id"])
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}") @router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async def get_teamspace(teamspace_id: int, request: Request): def get_teamspace(teamspace_id: int, request: Request):
user = _user(request) user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"]) ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id) ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members") @router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request): def list_members(teamspace_id: int, request: Request):
user = _user(request) user = _user(request)
_teamspace_or_404(teamspace_id, user["id"]) _teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn: with get_conn() as conn:
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}") @router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def remove_member(teamspace_id: int, member_id: int, request: Request): def remove_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request) user = _user(request)
_teamspace_or_404(teamspace_id, user["id"]) _teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id): if not wiki.can_write_teamspace(user["id"], teamspace_id):
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
# ── verified pages ───────────────────────────────────────────────────────── # ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification") @router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request): def get_verification(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
return {"verification": wiki.verification(page_id)} return {"verification": wiki.verification(page_id)}
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
@router.delete("/api/v2/wiki/pages/{page_id}/verify") @router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request): def unverify_page(page_id: int, request: Request):
user = _user(request) user = _user(request)
_page_or_404(page_id) _page_or_404(page_id)
if not wiki.unverify_page(page_id): if not wiki.unverify_page(page_id):
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/verified") @router.get("/api/v2/wiki/verified")
async def list_verified(request: Request): def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index.""" """Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request) user = _user(request)
wid = _workspace_id(request) wid = _workspace_id(request)
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
# ── follows ──────────────────────────────────────────────────────────────── # ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow") @router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request): def follow_page(page_id: int, request: Request):
user = _user(request) user = _user(request)
_page_or_404(page_id) _page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"]) now = wiki.toggle_follow(page_id, user["id"])
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/followers") @router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request): def list_followers(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
ids = wiki.followers(page_id) ids = wiki.followers(page_id)
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
@router.get("/api/v2/wiki/comments/{comment_id}/reactions") @router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request): def list_reactions(comment_id: int, request: Request):
_user(request) _user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)} return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/guests") @router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request): def list_guests(page_id: int, request: Request):
_user(request) _user(request)
_page_or_404(page_id) _page_or_404(page_id)
with get_conn() as conn: with get_conn() as conn:
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
@router.delete("/api/v2/wiki/guests/{share_id}") @router.delete("/api/v2/wiki/guests/{share_id}")
async def revoke_guest(share_id: int, request: Request): def revoke_guest(share_id: int, request: Request):
user = _user(request) user = _user(request)
with get_conn() as conn: with get_conn() as conn:
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone(): if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
@router.get("/g/{token}", response_class=HTMLResponse) @router.get("/g/{token}", response_class=HTMLResponse)
async def guest_page(token: str, request: Request): def guest_page(token: str, request: Request):
"""Account-less page access (read-only or commenter). 404 if inactive.""" """Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token) share = wiki.resolve_guest_share(token)
if not share: if not share:
@@ -465,7 +465,7 @@ Editing is disabled.</div>
# ── page views ───────────────────────────────────────────────────────────── # ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views") @router.get("/api/v2/wiki/pages/{page_id}/views")
async def page_views(page_id: int, request: Request): def page_views(page_id: int, request: Request):
user = _user(request) user = _user(request)
page = _page_or_404(page_id) page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]): if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
# ── wiki home ────────────────────────────────────────────────────────────── # ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home") @router.get("/api/v2/wiki/home")
async def wiki_home(request: Request): def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces.""" """Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request) user = _user(request)
wid = _workspace_id(request) wid = _workspace_id(request)
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
@router.post("/api/v2/wiki/verify-expiry-sweep") @router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request): def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job).""" """Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request) user = _user(request)
with get_conn() as conn: with get_conn() as conn:
+6 -6
View File
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
@router.get("/api/v2/workers") @router.get("/api/v2/workers")
async def list_workers(request: Request): def list_workers(request: Request):
_auth_user(request) _auth_user(request)
limit, offset = parse_pagination(request) limit, offset = parse_pagination(request)
with get_conn() as conn: with get_conn() as conn:
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
@router.get("/api/v2/workers/{worker_id}") @router.get("/api/v2/workers/{worker_id}")
async def get_worker(worker_id: int, request: Request): def get_worker(worker_id: int, request: Request):
user = _auth_user(request) user = _auth_user(request)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
@router.delete("/api/v2/workers/{worker_id}") @router.delete("/api/v2/workers/{worker_id}")
async def delete_worker(worker_id: int, request: Request): def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers/{worker_id}/runs") @router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request): def worker_runs(worker_id: int, request: Request):
_auth_user(request) _auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20) limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn: with get_conn() as conn:
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
@router.post("/api/v2/workers/{worker_id}/fork") @router.post("/api/v2/workers/{worker_id}/fork")
async def fork_worker_endpoint(worker_id: int, request: Request): def fork_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True) user = _auth_user(request, require_write=True)
out = worker_service.fork_worker(worker_id, user["id"]) out = worker_service.fork_worker(worker_id, user["id"])
audit_log(user, "worker.fork", "worker", worker_id, "", request) audit_log(user, "worker.fork", "worker", worker_id, "", request)
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers-usage") @router.get("/api/v2/workers-usage")
async def workers_usage(request: Request): def workers_usage(request: Request):
user = _auth_user(request) user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id") ws_raw = request.query_params.get("workspace_id")
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
+19 -19
View File
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
# ── Members ── # ── Members ──
@router.get("/{ws_id}/members") @router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int): def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required") raise HTTPException(401, "Authentication required")
with get_conn() as conn: with get_conn() as conn:
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/{ws_id}/members/{user_id}") @router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int): def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id) _require_ws_admin(request, ws_id)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ── Comments ── # ── Comments ──
@router.get("/pages/{page_id}/comments") @router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int): def list_comments(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at", "SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
# ── Page History ── # ── Page History ──
@router.get("/pages/{page_id}/history") @router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int): def page_history(request: Request, page_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50", "SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
# ── Favorites ── # ── Favorites ──
@router.get("/favorites") @router.get("/favorites")
async def list_favorites(request: Request): def list_favorites(request: Request):
user = _current_user(request) user = _current_user(request)
uid = user.get("id", 1) uid = user.get("id", 1)
with get_conn() as conn: with get_conn() as conn:
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
@router.delete("/favorites/{fav_id}") @router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int): def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,)) conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit() conn.commit()
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
# ── Templates ── # ── Templates ──
@router.get("/templates/database") @router.get("/templates/database")
async def list_db_templates(request: Request): def list_db_templates(request: Request):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall() rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]} return {"templates": [dict(r) for r in rows]}
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
@router.get("/collections/{collection_id}/templates/page") @router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int): def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,) "SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
@router.delete("/collections/{collection_id}/templates/page/{tid}") @router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int): def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template.""" """Delete a page template."""
with get_conn() as conn: with get_conn() as conn:
tmpl = conn.execute( tmpl = conn.execute(
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
# ── v4.2.0: Dashboards ── # ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards") @router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int): def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection.""" """List all dashboards for a collection."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
@router.delete("/collections/{collection_id}/dashboards/{did}") @router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int): def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard.""" """Delete a dashboard."""
with get_conn() as conn: with get_conn() as conn:
dash = conn.execute( dash = conn.execute(
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
# ── v4.5.0: Sprints ── # ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints") @router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int): def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection.""" """List all sprints for a collection."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}") @router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int): def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint.""" """Delete a sprint."""
with get_conn() as conn: with get_conn() as conn:
sprint = conn.execute( sprint = conn.execute(
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}") @router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int): def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint.""" """Remove a page from a sprint."""
with get_conn() as conn: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
@router.get("/collections/{collection_id}/sprints/burndown/{sid}") @router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int): def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint.""" """Calculate burndown data for a sprint."""
with get_conn() as conn: with get_conn() as conn:
sprint = conn.execute( sprint = conn.execute(
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/export/csv") @router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int): def export_csv(request: Request, collection_id: int):
with get_conn() as conn: with get_conn() as conn:
pages = conn.execute( pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,) "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
# ── Webhooks Outbound Management ── # ── Webhooks Outbound Management ──
@router.get("/webhooks") @router.get("/webhooks")
async def list_webhooks(request: Request): def list_webhooks(request: Request):
_require_admin(request) _require_admin(request)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall() rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}") @router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int): def delete_webhook(request: Request, wh_id: int):
_require_admin(request) _require_admin(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,)) conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ── # ── Public Sharing ──
@router.get("/public/{collection_id}") @router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int): def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required. """Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent A18 : les bases ``restricted``/``private`` (``permission_type``) restent
+13
View File
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
return {"status": "error", "detail": str(exc)} return {"status": "error", "detail": str(exc)}
def run_event_sync(coro, timeout: float = 60.0):
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
déterministe, exactement ce que faisait l'await avant la conversion des
routes en `def`.
ponytail: les clients httpx des services sont créés à chaque appel (aucun
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
"""
return asyncio.run(asyncio.wait_for(coro, timeout))
async def fire_event(event: str, payload: dict): async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations.""" """Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled). # v7.3.0: page.updated → in-app notification to followers (throttled).
+1 -1
View File
@@ -15,7 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download, open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################} ############################################################################}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════ /* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé. fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page Enregistré dans 'alpine:init' avec garde d'exécution : que la page
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px} .db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px} .db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
'use strict'; 'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button> </button>
{% endif %} {% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script> <script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()" <div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()"> @mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div> </div>
</header> </header>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return; if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true; if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -2
View File
@@ -10,7 +10,7 @@
############################################################################} ############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %} {% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */ /* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () { (function () {
var FD_ICONS = { var FD_ICONS = {
@@ -89,7 +89,7 @@
})(); })();
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () { (function () {
if (window.__fdIconPickerRegistered) return; if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true; window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div> </div>
</span> </span>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () { document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return; if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true; if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+1 -1
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;} .rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;} .rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */ /* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () { window.__fdRT = (function () {
+2 -2
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data"> <script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }} {{ page_data | tojson }}
</script> </script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */ /* eslint-disable */
if (!window.__fdEditorScriptsLoaded) { if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true; window.__fdEditorScriptsLoaded = true;
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() { function accountsData() {
return { return {
profile: { full_name: '', email: '' }, profile: { full_name: '', email: '' },
+2 -2
View File
@@ -204,7 +204,7 @@
</style> </style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #} {# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var defaultAPI = { var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ── // ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [ var FD_ADMIN_CMDS = [
+7 -4
View File
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; } body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; } body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style> </style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script> <script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script> <script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script> <script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers // Inject CSRF token into HTMX headers
(function() { (function() {
const getCsrf = () => { const getCsrf = () => {
@@ -2207,7 +2210,7 @@
</div> </div>
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){ (function(){
var PALETTE_ACTIONS = [ var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } }, { id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2378,7 +2381,7 @@
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #} {# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script> </script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #} {# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return; if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) { window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}'; var owner = '{{ owner }}';
var repo = '{{ repo }}'; var repo = '{{ repo }}';
var initialView = '{{ initial_view }}'; var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper // ponytail: CSRF helper
function getCsrf() { function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/); const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %} {% endfor %}
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function openCardDetail(id) { function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, { htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML' target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;} .gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => { Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
+1 -1
View File
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() { function importWizard() {
return { return {
sources: [], sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer> </footer>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() { window.addEventListener('load', function() {
+1 -1
View File
@@ -456,7 +456,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() { function libraryPage() {
return { return {
tab: 'recents', tab: 'recents',
+2 -2
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;} .preview-leave-end{transform:translateX(100%);opacity:0;}
</style> </style>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData'); console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() { window._wsInitData = (function() {
return { return {
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData; _wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length); console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script> </script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split" <div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)" @contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %} {% endblock %} {% block content %}
{% include "_database_table.html" %} {% include "_database_table.html" %}
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data // Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }}; window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }}; window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %} {% block topbar %}{% endblock %}
{% block content %} {% block content %}
{% include '_page_editor_content.html' %} {% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script> <script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% include '_page_editor_scripts.html' %} {% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer> </footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script> <script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (window.katex) { if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) { document.querySelectorAll('div[data-katex]').forEach(function (el) {
+1 -1
View File
@@ -1191,7 +1191,7 @@
</div> </div>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() { function settingsInit() {
return { return {
activeSection: 'account', activeSection: 'account',
+1 -1
View File
@@ -99,7 +99,7 @@
</div> </div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function tableView() { function tableView() {
return { return {
sortField: '', sortField: '',
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div> <div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div> </div>
<script> <script nonce="{{ csp_nonce() }}">
function teamLoad() { function teamLoad() {
return {}; return {};
} }
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() { function trashData() {
return { return {
search: '', search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section> </section>
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() { function onboarding() {
return { return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'], steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() { function workspacePage() {
return { return {
builtinProjects: [], builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div> </div>
<script data-cfasync="false"> <script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() { function workspacesPage() {
return { return {
workspaces: [], workspaces: [],
+7
View File
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
from __future__ import annotations from __future__ import annotations
import pathlib import pathlib
from contextvars import ContextVar
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
ENV = Environment( ENV = Environment(
loader=FileSystemLoader("app/templates"), loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]), autoescape=select_autoescape(["html"]),
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
ASSET_VERSION = "dev" ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
+518 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.5.0" "version": "7.15.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
@@ -10765,47 +10765,6 @@
} }
} }
}, },
"/api/library/local-workspace-children/{item_id}": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace Children",
"description": "Return children of a local workspace item for tree expansion.",
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Item Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/children/{page_id}": { "/api/library/children/{page_id}": {
"get": { "get": {
"tags": [ "tags": [
@@ -10899,48 +10858,6 @@
} }
} }
}, },
"/api/library/local-workspace": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace",
"description": "Return local workspace items (files/folders) formatted for Library display.",
"operationId": "library_local_workspace_api_library_local_workspace_get",
"parameters": [
{
"name": "workspace_id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"title": "Workspace Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/workspace": { "/api/library/workspace": {
"get": { "get": {
"tags": [ "tags": [
@@ -16725,6 +16642,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -16974,6 +16902,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17123,6 +17062,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17233,6 +17183,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17397,6 +17358,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17462,6 +17434,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17617,6 +17600,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17727,6 +17721,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -17837,6 +17842,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18003,6 +18019,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18178,6 +18205,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18288,6 +18326,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18454,6 +18503,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18564,6 +18624,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18674,6 +18745,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18849,6 +18931,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -18905,6 +18998,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19015,6 +19119,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19062,6 +19177,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19109,6 +19235,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19219,6 +19356,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19275,6 +19423,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19385,6 +19544,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19495,6 +19665,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19551,6 +19732,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19715,6 +19907,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19771,6 +19974,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -19881,6 +20095,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20125,6 +20350,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20217,6 +20453,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20365,6 +20612,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20421,6 +20679,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20531,6 +20800,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20753,6 +21033,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20809,6 +21100,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -20919,6 +21221,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21085,6 +21398,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21195,6 +21519,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21251,6 +21586,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21361,6 +21707,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21592,6 +21949,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21648,6 +22016,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -21861,6 +22240,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22367,6 +22757,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22618,6 +23019,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22674,6 +23086,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "description": "Successful Response",
@@ -22961,6 +23384,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Successful Response", "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" 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(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app
+502
View File
@@ -0,0 +1,502 @@
"""A32 — smoke tests : un par route des 4 routers à 0 test.
`webhooks.py` (3), `notes.py` (2), `sidebar_config.py` (2), `github_routes.py` (2).
Aucun accès réseau réel : le secret webhook est piloté par monkeypatch et
`gitea.list_webhooks` est stubbé.
"""
from app.config import settings
OWNER, REPO = "smoke-a32", "repo-x"
# ── webhooks.py (0/3) ────────────────────────────────────────────────────────
def test_webhook_receive_ok_without_secret(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post("/api/webhook", json={"zen": "smoke"})
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_webhook_receive_rejects_bad_signature(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "s3cret")
r = client.post("/api/webhook", json={}) # pas d'en-tête X-Gitea-Signature
assert r.status_code == 401
assert r.json()["detail"] == "Invalid signature"
def test_webhook_register_fails_fast_without_secret(client, monkeypatch):
# secret vide → 400 AVANT tout appel réseau (gitea.list_webhooks)
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post(f"/api/webhook/register/{OWNER}/{REPO}")
assert r.status_code == 400
assert "not configured" in r.json()["detail"]
def test_webhook_status_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _no_hooks(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "list_webhooks", _no_hooks)
r = client.get(f"/api/webhook/status/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"registered": False}
# ── notes.py (0/2) ───────────────────────────────────────────────────────────
def test_notes_get_renders_html(client):
r = client.get(f"/notes/{OWNER}/{REPO}")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_notes_save_roundtrip(client):
payload = "note de smoke A32"
html_payload = "<b>&co</b>"
r = client.post(
f"/notes/{OWNER}/{REPO}", data={"content": f"{payload} {html_payload}"}
)
assert r.status_code == 200
assert payload in r.text # contenu rendu
assert "&lt;b&gt;&amp;co&lt;/b&gt;" in r.text # échappé (A10 — pas d'HTML cru)
again = client.get(f"/notes/{OWNER}/{REPO}")
assert payload in again.text # persistée en base (upsert)
# ── sidebar_config.py (0/2) ──────────────────────────────────────────────────
def test_sidebar_config_get(client):
r = client.get("/api/sidebar/config")
assert r.status_code == 200
cfg = r.json()["config"]
assert isinstance(cfg, dict) and "meetings" in cfg
def test_sidebar_config_put_persists_and_validates(client):
r = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": False, "order": 2, "show_count": 5}}},
)
assert r.status_code == 200
back = client.get("/api/sidebar/config").json()["config"]
assert back["meetings"]["visible"] is False # relu depuis users.sidebar_config
bad = client.put("/api/sidebar/config", json={})
assert bad.status_code == 400 # config dict requis
# remise en état pour les autres tests
restore = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": True, "order": 2, "show_count": 5}}},
)
assert restore.status_code == 200
# ── github_routes.py (0/2) ───────────────────────────────────────────────────
def test_github_status_unlinked(client):
r = client.get("/api/github/status")
assert r.status_code == 200
assert r.json() == {"linked": False}
def test_github_disconnect_ok(client):
r = client.delete("/api/github/disconnect")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
# ── library.py (1/10 → 10/10) ────────────────────────────────────────────────
def _seed_page(title: str, **cols) -> int:
"""Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé."""
from app.db import get_conn
base = {"workspace": "", "title": title, "content": "", "content_format": "markdown"}
base.update(cols) # surcharge (content=, parent_id=, parent_section=…)
columns = list(base)
placeholders = ", ".join("?" for _ in columns)
values = list(base.values())
with get_conn() as conn:
cur = conn.execute(
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
)
pid = cur.lastrowid
conn.commit()
return pid
def test_library_all_lists_return_items(client):
"""Les 5 listes de la bibliothèque répondent {"items": [...]}."""
for path in ("/recents", "/favorites", "/published", "/private", "/workspace"):
r = client.get(f"/api/library{path}")
assert r.status_code == 200, (path, r.status_code)
assert isinstance(r.json()["items"], list), path
def test_library_private_lists_seeded_page(client):
from app.db import get_conn
pid = _seed_page("A32 private page", parent_section="Private")
try:
items = client.get("/api/library/private").json()["items"]
assert "A32 private page" in [i["title"] for i in items]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_library_children_lists_child(client):
from app.db import get_conn
parent = _seed_page("A32 parent")
child = _seed_page("A32 child", parent_id=parent)
try:
items = client.get(f"/api/library/children/{parent}").json()["items"]
assert [i["title"] for i in items] == ["A32 child"]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (parent, child))
conn.commit()
def test_library_local_workspace_routes_removed(client):
"""A32 → A30 : ces 2 routes lisaient `local_workspace_items`, une table
inexistante nulle part dans le codebase (500 systématique côté children,
500 d?s qu'un workspace existe côté local-workspace) et AUCUNE référence
front → supprimées avec `_format_size` devenu mort."""
assert client.get("/api/library/local-workspace-children/1").status_code == 404
assert client.get("/api/library/local-workspace").status_code == 404
def test_library_repository_empty_and_keyed(client):
empty = client.get("/api/library/repository")
assert empty.status_code == 200 and empty.json() == {"items": []}
keyed = client.get("/api/library/repository?gitea_owner=acme&gitea_repo=flowdeck")
assert keyed.status_code == 200
assert isinstance(keyed.json()["items"], list)
def test_library_lists_still_available_after_removal(client):
"""Les listes restantes (celles qu'utilise le front) répondent toujours."""
r = client.get("/api/library/recents")
assert r.status_code == 200 and isinstance(r.json()["items"], list)
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
def _seed_board(client) -> None:
"""Board via l'endpoint lui-même (pas de SQL brut)."""
r = client.post(
f"/api/board-config/{OWNER}/{REPO}",
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
def test_api_board_config_default_then_roundtrip(client):
# pas de board → défauts
r = client.get(f"/api/board-config/{OWNER}/other-repo")
assert r.status_code == 200
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
# création puis relecture
_seed_board(client)
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
assert back.json()["columns"] == ["Todo", "Doing"]
def test_api_col_mapping_requires_board_and_works(client):
# board inexistant → 404
r = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
)
assert r.status_code == 404
# avec board → ok (upsert) puis suppression
_seed_board(client)
r2 = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
)
assert r2.status_code == 200
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
r3 = client.delete(
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
)
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
def test_api_card_metadata(client):
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
assert r.status_code == 404 # board requis
_seed_board(client)
r2 = client.post(
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
)
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
def test_api_collaborators_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _none(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"collaborators": []}
def test_api_checklist_item_toggle_and_deletes(client):
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
from app.db import get_conn
_seed_board(client)
with get_conn() as conn:
board_id = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(OWNER, REPO),
).fetchone()["id"]
cur = conn.execute(
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
(board_id,),
)
cl_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
(cl_id,),
)
item_id = cur.lastrowid
conn.commit()
try:
r = client.patch(
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
).fetchone()
assert row["checked"] == 1 and row["content"] == "fait !"
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
with get_conn() as conn:
left = conn.execute(
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
).fetchone()["n"]
assert left == 0
finally:
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
conn.commit()
def _v2_headers(client, login: str) -> dict:
"""Compte local + token v1 en session (recette de test_public_api_v2)."""
r = client.post(
"/auth/register",
json={"email": f"{login}@test.dev", "password": "secret123", "name": login},
)
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
return {"Authorization": f"Bearer {tok}"}
def test_api_frontend_error_capture_and_read(client):
# purge d'un éventuel résidu d'un autre test
client.get("/api/frontend-errors")
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
invalid = client.post(
"/api/frontend-error", content="pas du json",
headers={"Content-Type": "application/json"},
)
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
# doublon consécutif dédupliqué (count=2), lecture puis purge
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
out = client.get("/api/frontend-errors").json()
assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True
assert client.get("/api/frontend-errors").json()["count"] == 0
# ── api_v2.py (+5 routes à 0 ref) ────────────────────────────────────────────
def test_v2_evaluate_formula(client):
headers = _v2_headers(client, "smoke-formula")
r = client.post(
"/api/v2/properties/evaluate-formula",
json={"expression": "1 + 2"}, headers=headers,
)
assert r.status_code == 200
d = r.json()
# le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) :
# on valide le câble route (auth, parse, shape), pas le moteur lui-même.
assert d["expression"] == "1 + 2" and "result" in d
missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers)
assert missing.status_code == 400 and "expression" in missing.json()["detail"]
def test_v2_compute_rollup_validates_inputs(client):
headers = _v2_headers(client, "smoke-rollup")
r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers)
assert r.status_code == 400
assert "collection_id required" in r.json()["detail"]
sans_auth = client.post("/api/v2/properties/compute-rollup", json={})
assert sans_auth.status_code == 401
def test_v2_admin_audit_logs_gated_then_readable(client):
headers = _v2_headers(client, "smoke-audit")
# le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur
# du worker est admin) → on calcule l'attendu depuis /users/me plutôt que
# de durcir un état qu'on ne contrôle pas.
me = client.get("/api/v2/users/me", headers=headers).json()
r = client.get("/api/v2/admin/audit-logs", headers=headers)
if me.get("is_admin"):
assert r.status_code == 200
else:
assert r.status_code == 403
assert "Admin scope" in r.json()["detail"]
# token avec scope admin → 200 + liste paginée
admin_tok = client.post(
"/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers
).json()["token"]
r2 = client.get(
"/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"}
)
assert r2.status_code == 200
assert isinstance(r2.json()["logs"], list)
def test_v2_webhooks_events_catalogue(client):
headers = _v2_headers(client, "smoke-events")
r = client.get("/api/v2/webhooks/events", headers=headers)
assert r.status_code == 200
d = r.json()
assert isinstance(d["events"], list) and len(d["events"]) > 0
assert "*" in d["wildcards"] and "page.*" in d["wildcards"]
def test_v2_webhook_verify_signature(client):
from app.services.webhook_outbound import sign_payload
headers = _v2_headers(client, "smoke-sig")
secret, payload = "topsecret", '{"event": "page.updated"}'
good = sign_payload(secret, payload.encode())
ok = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": good},
headers=headers,
)
assert ok.status_code == 200 and ok.json()["valid"] is True
bad = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": "deadbeef"},
headers=headers,
)
assert bad.json()["valid"] is False
# ── dashboard.py (17/63 → 27/63) ─────────────────────────────────────────────
def test_dashboard_tags_crud(client):
"""POST/GET/PUT/DELETE /api/settings/tags — cycle complet vérifié."""
r = client.post("/api/settings/tags", json={"name": "SmokeTag", "color": "#111111"})
assert r.status_code == 200
tag = r.json()["tag"]
assert tag["name"] == "smoketag" and tag["color"] == "#111111" # lowercasé
allr = client.get("/api/settings/tags/all").json()["tags"]
assert any(t["id"] == tag["id"] for t in allr)
assert client.put(
f"/api/settings/tags/{tag['id']}", json={"color": "#222222"}
).json() == {"status": "ok"}
allr2 = client.get("/api/settings/tags/all").json()["tags"]
mine = next(t for t in allr2 if t["id"] == tag["id"])
assert mine["color"] == "#222222"
assert client.delete(f"/api/settings/tags/{tag['id']}").json() == {"status": "ok"}
assert all(t["id"] != tag["id"] for t in client.get("/api/settings/tags/all").json()["tags"])
def test_dashboard_page_content_rename_trash(client):
"""GET content → PUT rename → POST trash : la vie d'une page vérifiée en base."""
from app.db import get_conn
pid = _seed_page("Page A32", content="contenu a32")
try:
r = client.get(f"/api/pages/{pid}/content")
assert r.status_code == 200
d = r.json()
assert d["title"] == "Page A32" and d["content"] == "contenu a32"
rr = client.put(f"/api/pages/{pid}/rename", json={"title": "Renommée A32"})
assert rr.status_code == 200 and rr.json() == {"status": "ok", "title": "Renommée A32"}
empty = client.put(f"/api/pages/{pid}/rename", json={"title": " "})
assert empty.status_code == 400 # titre vide refusé
rt = client.post(f"/api/pages/{pid}/trash")
assert rt.status_code == 200 and rt.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_dashboard_sidebar_workspace_tree(client):
r = client.get("/api/sidebar/workspace-tree")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# pas de cookie workspace → fragment « No pages yet »
assert "No pages yet" in r.text
def test_dashboard_avatar_color_persisted(client):
"""POST avatar-color → couleur relue SUR L'UTILISATEUR DE LA SESSION."""
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
row = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
prev_color, prev_url = row["avatar_color"], row["avatar_url"]
try:
r = client.post("/api/settings/avatar-color", json={"color": "#112233"})
assert r.status_code == 200
assert r.json() == {"status": "ok", "color": "#112233"}
with get_conn() as conn:
row2 = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
assert row2["avatar_color"] == "#112233" and row2["avatar_url"] == ""
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET avatar_color=?, avatar_url=? WHERE id=?",
(prev_color, prev_url, uid),
)
conn.commit()
def test_dashboard_workspace_members_list(client):
r = client.get("/api/workspace/1/members")
assert r.status_code == 200
assert isinstance(r.json()["members"], list)