Compare commits

..
8 Commits
Author SHA1 Message Date
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
bruno 7be96f0618 fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
  404 si la page n'existe pas, événements centralisés. Les 3 paires
  publish/unpublish déléguent (sharing = front, board, v2) :
  · board : mise à jour aveugle → 404 + contrôle de session ajouté
  · board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
    le share dialog reste l'unique propriétaire de ces drapeaux
  · v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
  · `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
    décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
  faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
  exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
  → `settings.data_dir` (property : lecture à chaque accès, les tests
  monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
  écriture. Reste : client httpx partagé (52 créations, cache par event loop)

tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired

suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 10:01:39 -04:00
bruno 937ecfc2e0 fix: A30 + A37 + A39 + A40 + A41 — fin du P2/P3 XS/S (v7.4.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la
  factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique
  alignée sur celle des handlers (pas de default "read" → 0 changement de
  comportement) ; 12 top-level morts supprimés (0 ref app ET tests) :
  unsync_block, find_referring, _b64url, strip_markdown, format_number,
  get_auto_property_value, get_next_unique_id, local_date_in_tz,
  verify_device_token, _get_dynamic_groups, _require_user_gitea,
  validate_upload_request
- A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex
  (localhost/dev, origines d'extension pour le Web Clipper), méthodes et
  entêtes minutées, allow_credentials explicite + test test_cors_no_star
- A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6
  templates, conversion = refonte du view-switching sans test E2E)
- A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au
  boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ;
  test test_asset_version_single_source
- A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344),
  scan templates/JS/CSS/Python à 0 référence

suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 09:30:37 -04:00
bruno 998b5c630c docs(roadmap): A43 marque partiel — placeholder CSRF et palette restent ouverts
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Les deux sous-items JS de A43 ne sont pas traits (utcnow et health log le sont).
2026-10-01 08:51:12 -04:00
99 changed files with 1759 additions and 1625 deletions
+177
View File
@@ -1,5 +1,182 @@
# Changelog - FlowDeck
## 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)
### Changed
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
fourni conservé), événements centralisés, board gagne le contrôle de session.
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
`settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
à faire avec un cache par event loop)
### Security
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
`login_attempts` et `locked_until` exposés → colonnes whitelistées
(identiques à la liste v2)
### Tests
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
### Changed
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
dérivées de `settings.app_base_url` + localhost/origines d'extension
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
explicite ; test `test_cors_no_star`
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
templates, JS, autres CSS et code Python
### Notes
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
automatisé du view-switch
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
+12 -12
View File
@@ -1137,8 +1137,8 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
- [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Reste** : `unsafe-eval` (Alpine x-data en string → build `@alpinejs/csp`), externalisation JS (A27), resserrer `img-src`/`connect-src`, vendoriser les 2 CDN. Effort : **L**.
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Reste (phase 2c)** : **260 routes async repo-wide** (dashboard/collections/board/…) → mêmes deux recettes (Body param, `run_event_sync` ou `to_thread` par bloc DB). Effort : **L**.
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
@@ -1149,24 +1149,24 @@ Quality DB views, Agent IA Palette → Realtime + E
- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
- [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
### 🟢 P3 — Basses / hygiène
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
- [x] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
- [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
- [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [x] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé.* — **fait 2026-10-01 (partiel)** : les 29 `Environment(...)` = A10 ✓ ; les 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` → `settings.data_dir` (property, lecture à chaque accès car les tests monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque écriture. **Reste** : le client httpx partagé (52 créations — un `AsyncClient` module-level est fragile avec les event loops de tests, à faire avec un cache par loop). Effort : **M**.
- [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker)
@@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9.**
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0.
+1 -1
View File
@@ -1 +1 @@
7.3.9
7.10.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.10.0 (audit — A21 phase 2b : api_v2 bouclé, 111/115 hors loop) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
-2
View File
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
+10
View File
@@ -1,12 +1,22 @@
"""FlowDeck — Configuration via pydantic-settings."""
from __future__ import annotations
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@property
def data_dir(self) -> str:
"""Racine des fichiers (avatars, uploads…).
Pas un champ : la lecture est faite à chaque accès parce que les tests
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
"""
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
+23 -8
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.9",
version="7.10.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -195,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
app.add_middleware(CSRFMiddleware)
app.add_middleware(ContentSecurityPolicyMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# A37 : origines explicites (l'auth est un cookie de session ; le front est
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
allow_credentials=True,
)
app.include_router(auth.router)
app.include_router(sso_router)
@@ -250,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
def pwa_manifest():
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
@@ -267,7 +282,7 @@ async def service_worker():
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
import secrets
@@ -282,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
@app.get("/api/pages")
async def api_pages_alias(request: Request):
def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse
qs = str(request.url.query)
@@ -291,7 +306,7 @@ async def api_pages_alias(request: Request):
@app.post("/api/pages")
async def api_pages_post_alias(request: Request):
def api_pages_post_alias(request: Request):
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/board/api/pages", status_code=307)
@@ -326,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(_StarHTTPException)
async def http_exception_handler(request: Request, exc: _StarHTTPException):
def http_exception_handler(request: Request, exc: _StarHTTPException):
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
Registered on Starlette's HTTPException (the base class) so it catches both
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import ipaddress
import secrets
import time
from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""
CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
# chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
)
async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response
+61 -28
View File
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
)
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
unitaire se lit déjà dans le set.
"""
if not table.replace("_", "").isalnum():
raise ValueError(f"nom de table invalide: {table!r}")
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
_apply_one(conn, version, name, fn)
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
"""A31 : une migration = une transaction (DDL tout-ou-rien).
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
rollback complet à l'échec, donc la prochaine exécution retente proprement.
"""
if conn.in_transaction:
# transaction résiduelle du caller (init_db commit juste avant) — on
# part d'un état propre plutôt que d'englober son travail.
conn.commit()
conn.execute("BEGIN")
try:
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
except BaseException:
conn.rollback()
raise
# ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_pcols = columns(conn, "api_tokens")
if "id" not in _pcols:
conn.execute(
"""
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
_scols = columns(conn, "user_sessions")
if "id" not in _scols:
conn.execute(
"""
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
_projcols = columns(conn, "projects")
if "id" not in _projcols:
conn.execute(
"""
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
_cols = columns(conn, "database_templates")
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
_vcols = columns(conn, "collection_views")
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
_cpcols = columns(conn, "collection_pages")
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
_ucols = columns(conn, "users")
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
_ccols = columns(conn, "collections")
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
``idempotency_keys`` — Idempotency-Key support for POST creations.
"""
# api_tokens extra columns
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_cols = columns(conn, "api_tokens")
if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols:
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt).
"""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
_cols = columns(conn, "webhook_deliveries")
if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols:
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
cols = columns(conn, "collections")
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)"
)
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
auto_cols = columns(conn, "automations")
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
prop_cols = columns(conn, "collection_properties")
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
cols = columns(conn, "collection_pages")
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)"
)
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
user_cols = columns(conn, "users")
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
+1 -18
View File
@@ -1,10 +1,9 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
# ── File Save ────────────────────────────────────────────────
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
error: str | None = None
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
+4 -4
View File
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
# ── Users ──
@router.get("/users")
async def list_users(_admin=Depends(admin_required)):
def list_users(_admin=Depends(admin_required)):
"""List all users with workspace/file/folder counts and storage usage."""
from app.db import get_conn
with get_conn() as conn:
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
@router.delete("/users/{user_id:int}")
async def delete_user(user_id: int, _admin=Depends(admin_required)):
def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data."""
from app.db import get_conn
with get_conn() as conn:
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
# ── Stats ──
@router.get("/stats")
async def user_stats(_admin=Depends(admin_required)):
def user_stats(_admin=Depends(admin_required)):
"""Aggregate stats: total users, workspaces, files, storage."""
from app.db import get_conn
with get_conn() as conn:
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
# ── Audit ──
@router.get("/audit")
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
"""Recent login history."""
from app.db import get_conn
with get_conn() as conn:
+10 -10
View File
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
@router.get("/conversations/{conversation_id}")
async def get_conversation(request: Request, conversation_id: int):
def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: int):
def delete_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
raise HTTPException(status_code=404, detail="Conversation introuvable")
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
@router.get("/conversations/{conversation_id}/actions")
async def list_actions(request: Request, conversation_id: int):
def list_actions(request: Request, conversation_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
@router.post("/actions/{action_id}/undo")
async def undo(request: Request, action_id: int):
def undo(request: Request, action_id: int):
try:
undo_action(action_id)
except ValueError as exc:
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
@router.get("/skills/gallery")
async def skills_gallery(request: Request):
def skills_gallery(request: Request):
presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets),
"install": "POST /api/agent/skills/gallery/{slug}/install"}
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
@router.get("/skills/{skill_id}/export")
async def export_skill(request: Request, skill_id: int):
def export_skill(request: Request, skill_id: int):
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
@router.delete("/skills/{skill_id}")
async def delete_skill(request: Request, skill_id: int):
def delete_skill(request: Request, skill_id: int):
with get_conn() as conn:
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
@router.get("/tools")
async def list_tools(request: Request):
def list_tools(request: Request):
registry = ToolRegistry()
tools = registry.schema()
return {"tools": tools}
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
@router.get("/{agent_id}")
async def get_agent(request: Request, agent_id: int):
def get_agent(request: Request, agent_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row:
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
@router.delete("/{agent_id}")
async def delete_agent(request: Request, agent_id: int):
def delete_agent(request: Request, agent_id: int):
with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
+20 -15
View File
@@ -83,7 +83,7 @@ async def health(request: Request):
@router.get("/stats")
async def stats():
def stats():
"""Global stats for dashboard."""
with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
@router.post("/col-mapping")
async def set_col_mapping(
def set_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -209,7 +209,7 @@ async def set_col_mapping(
@router.delete("/col-mapping")
async def delete_col_mapping(
def delete_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -234,7 +234,7 @@ async def delete_col_mapping(
@router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
def get_board_config(owner: str, repo: str):
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
@router.post("/board-config/{owner}/{repo}")
async def update_board_config(
def update_board_config(
owner: str,
repo: str,
columns: str = Query(default=""),
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
# ── v0.5.0: Checklists ──
@router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist(
def create_checklist(
owner: str,
repo: str,
issue_id: int,
@@ -484,7 +484,7 @@ async def create_checklist(
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item(
def add_checklist_item(
owner: str,
repo: str,
issue_id: int,
@@ -502,7 +502,7 @@ async def add_checklist_item(
@router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item(
def toggle_checklist_item(
item_id: int,
checked: bool = Query(default=False),
content: str = Query(default=""),
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
@router.delete("/checklist-items/{item_id}")
async def delete_checklist_item(item_id: int):
def delete_checklist_item(item_id: int):
"""Delete a checklist item."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
@router.delete("/checklists/{checklist_id}")
async def delete_checklist(checklist_id: int):
def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
@@ -545,14 +545,19 @@ async def delete_checklist(checklist_id: int):
# ── v1.0.0: User management ──
@router.get("/users/me")
async def get_my_profile(request: Request):
def get_my_profile(request: Request):
"""Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn:
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
# password_hash, login_attempts et locked_until.
row = conn.execute(
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, last_login, created_at "
"FROM users WHERE login=?",
(user.get("login", ""),),
).fetchone()
if row:
return dict(row)
@@ -560,7 +565,7 @@ async def get_my_profile(request: Request):
@router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""),
def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")):
"""Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -579,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
# ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card(
def update_card(
owner: str,
repo: str,
issue_id: int,
@@ -668,7 +673,7 @@ async def capture_frontend_error(request: Request):
@router.get("/frontend-errors")
async def get_frontend_errors(request: Request, clear: bool = True):
def get_frontend_errors(request: Request, clear: bool = True):
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
errors = list(_frontend_errors)
if clear:
+205 -540
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
# ── Agents ─────────────────────────────────────────────────────────────────
@router.get("/agents")
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
limit, offset = parse_pagination(request)
ws = _workspace_of(request)
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
@router.get("/agents/{agent_id}")
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
@router.delete("/agents/{agent_id}")
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
@router.get("/agents/conversations")
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
@router.get("/agents/conversations/{conversation_id}")
async def get_conversation_v2(conversation_id: int, request: Request,
def get_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
with get_conn() as conn:
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
@router.delete("/agents/conversations/{conversation_id}")
async def delete_conversation_v2(conversation_id: int, request: Request,
def delete_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
@router.get("/agents/conversations/{conversation_id}/actions")
async def list_actions_v2(conversation_id: int, request: Request,
def list_actions_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
with get_conn() as conn:
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
@router.post("/agents/actions/{action_id}/undo")
async def undo_action_v2(action_id: int, request: Request,
def undo_action_v2(action_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
# ── Skill marketplace ──────────────────────────────────────────────────────
@router.get("/skills")
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
limit, offset = parse_pagination(request)
ws = _workspace_of(request)
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
# Gallery & import are static segments: declared before /skills/{skill_id} so
# FastAPI never tries to coerce "gallery" into an int path parameter.
@router.get("/skills/gallery")
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets),
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
@router.get("/skills/{skill_id}")
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
@router.get("/skills/{skill_id}/export")
async def export_skill_v2(skill_id: int, request: Request,
def export_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)):
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
_guard(request, authorization)
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
@router.delete("/skills/{skill_id}")
async def delete_skill_v2(skill_id: int, request: Request,
def delete_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
+1 -1
View File
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
@router.get("/api/v2/audit/logs")
async def audit_logs(request: Request):
def audit_logs(request: Request):
_admin_user(request)
qp = request.query_params
source = (qp.get("source") or "all").lower()
+19 -9
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register")
async def register_page(request: Request):
def register_page(request: Request):
"""Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"'
).replace(
@@ -163,16 +173,16 @@ async def register_page(request: Request):
).replace(
'id="submit-btn">Login<',
'id="submit-btn">Register<'
), status_code=200)
)), status_code=200)
@router.get("/login")
async def login(request: Request, provider: str = Query("gitea")):
def login(request: Request, provider: str = Query("gitea")):
"""Redirect to OAuth2 authorize page or show local login page."""
# Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse
if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured
from app.auth.providers import get_provider
@@ -450,7 +460,7 @@ async def callback(
@router.get("/logout")
async def logout(request: Request):
def logout(request: Request):
"""Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
@router.get("/2fa/status")
async def twofa_status(request: Request):
def twofa_status(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return {"enabled": _2fa.is_enabled(user["id"]),
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
@router.post("/2fa/setup")
async def twofa_setup(request: Request):
def twofa_setup(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return _2fa.setup_secret(user["id"])
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
@router.post("/2fa/disable")
async def twofa_disable(request: Request):
def twofa_disable(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
_2fa.disable(user["id"])
+6 -6
View File
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
@router.get("/workspace/automations")
async def list_automations(request: Request):
def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
@router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int):
def get_automation(request: Request, auto_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
@router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int):
def delete_automation(request: Request, auto_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
@router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=?
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
@router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int):
def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None:
return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
@router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int):
def delete_step(request: Request, step_id: int):
_require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
+35 -58
View File
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -64,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
@router.get("/api/wiki/pages")
async def wiki_page_search(request: Request, q: str = Query(default="")):
def wiki_page_search(request: Request, q: str = Query(default="")):
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
every non-deleted page the current user can see (single source: pages)."""
q = (q or "").strip().lower()
@@ -96,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
@router.get("/api/wiki/titles")
async def wiki_titles(request: Request, ids: str = Query(default="")):
def wiki_titles(request: Request, ids: str = Query(default="")):
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
parsed: list[int] = []
for part in (ids or "").split(","):
@@ -177,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
@router.get("/api/page-templates")
async def list_page_templates_api(request: Request):
def list_page_templates_api(request: Request):
"""v5.12.0: built-in + user global page templates for the picker."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
@@ -850,15 +852,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
return [dict(r) for r in rows]
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
"""Return groups from Gitea labels/milestones or fallback to defaults."""
try:
labels = json.loads(
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
) if False else []
except Exception:
labels = []
return labels if labels else ["Design", "Engineering", "No Team"]
async def asyncio_get_labels(owner: str, repo: str):
@@ -906,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
# ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
from app.templating import ENV
env = ENV
@@ -950,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
# ═══════════ Favorites API ═══════════
@router.get("/api/favorites")
async def list_favorites(request: Request):
def list_favorites(request: Request):
"""List favorited page IDs for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -1020,42 +1013,27 @@ async def update_share(request: Request, page_id: int):
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
import secrets
slug = "p-" + secrets.token_urlsafe(8)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
(slug, page_id),
)
conn.commit()
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
slug, title = publish(page_id)
await fire_published(page_id, slug)
return {"is_published": True, "publish_slug": slug, "title": title}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page")
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
unpublish(page_id)
await fire_unpublished(page_id)
return {"is_published": False}
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
async def list_trash(request: Request):
def list_trash(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
@@ -1074,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}")
async def permanent_delete(request: Request, page_id: int):
def permanent_delete(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
@@ -1083,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
def trash_page(request: Request):
from app.templating import ENV
env = ENV
template = env.get_template("trash.html")
@@ -1094,7 +1072,7 @@ async def trash_page(request: Request):
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks
@@ -1169,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int):
def get_synced_block_api(sid: int):
"""Get a synced block by id."""
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
@@ -1195,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid)
@@ -1203,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
@router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int):
def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)}
@@ -1212,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, owner, repo)
@@ -1301,12 +1279,12 @@ async def board_view(
# ═══════════ v0.9.0: Custom Properties API ═══════════
@router.get("/api/properties/{owner}/{repo}")
async def get_properties(owner: str, repo: str):
def get_properties(owner: str, repo: str):
return {"properties": _get_project_properties(owner, repo)}
@router.post("/api/properties/{owner}/{repo}")
async def create_property(owner: str, repo: str, name: str = Query(...),
def create_property(owner: str, repo: str, name: str = Query(...),
prop_type: str = Query(default="select"),
options: str = Query(default="")):
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
@@ -1323,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
@router.delete("/api/properties/{owner}/{repo}")
async def delete_property(owner: str, repo: str, name: str = Query(...)):
def delete_property(owner: str, repo: str, name: str = Query(...)):
with get_conn() as conn:
conn.execute(
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
@@ -1334,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
@router.post("/api/properties/{owner}/{repo}/values")
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
name: str = Query(...), value: str = Query(default="")):
with get_conn() as conn:
prop = conn.execute(
@@ -1354,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
# ═══════════ v0.9.0: AI Keywords API ═══════════
@router.get("/api/ai-keywords/{owner}/{repo}")
async def get_ai_keywords(owner: str, repo: str):
def get_ai_keywords(owner: str, repo: str):
with get_conn() as conn:
rows = conn.execute(
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
@@ -1417,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
@router.get("/api/pages/{page_id}")
async def get_page(request: Request, page_id: int):
def get_page(request: Request, page_id: int):
"""Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
@@ -1565,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
@router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int):
def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal
@@ -1613,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int):
def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page."""
with get_conn() as conn:
rows = conn.execute(
@@ -1695,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _ws_id_for(request: Request, page_id: int) -> int:
@@ -1773,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int):
def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit()
@@ -2047,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
+2 -2
View File
@@ -49,7 +49,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page."""
_current_user(request)
with get_conn() as conn:
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
@router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int):
def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies."""
user = _current_user(request)
with get_conn() as conn:
+29 -28
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict:
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
@router.get("", response_class=HTMLResponse)
async def list_collections(request: Request):
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
@router.get("/api")
async def list_collections_api(request: Request):
def list_collections_api(request: Request):
"""API: list all collections."""
with get_conn() as conn:
rows = conn.execute(
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
@router.get("/pages/{page_id}/api")
async def get_page_api(request: Request, page_id: int):
def get_page_api(request: Request, page_id: int):
"""API: get a single page."""
with get_conn() as conn:
page = conn.execute(
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
@router.get("/pages/{page_id}/open/api")
async def open_row_page_api(request: Request, page_id: int):
def open_row_page_api(request: Request, page_id: int):
"""v6.5.0 — content page of a database row (lazy-created).
Any DB view (table/board/gallery/list/calendar) opens a row through
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
@router.get("/boards/api")
async def list_boards_as_collections(request: Request):
def list_boards_as_collections(request: Request):
"""API: list all Gitea boards as pseudo-collections."""
from app.services.collection_adapter import GiteaBoardCompat
boards = GiteaBoardCompat.list_boards_as_collections()
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
@router.get("/property-types/api")
async def list_property_types_api(request: Request):
def list_property_types_api(request: Request):
"""API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int):
def list_properties_api(request: Request, collection_id: int):
"""API: list all properties visible to the current user."""
user = _session_user(request)
_require_view(collection_id, user)
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
@router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int):
def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
@router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int,
def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end].
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
@router.get("/timezones/api")
async def timezones_api(request: Request):
def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request)
with get_conn() as conn:
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
@router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int):
def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
@router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int):
def get_view_api(request: Request, view_id: int):
"""API: get a single view config."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
def list_views_api(request: Request, collection_id: int):
"""API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
@router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int):
def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
@router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int):
def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page."""
with get_conn() as conn:
rows = conn.execute(
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children."""
with get_conn() as conn:
children = conn.execute(
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
@router.get("/{collection_id}/sources/api")
async def list_data_sources(request: Request, collection_id: int):
def list_data_sources(request: Request, collection_id: int):
"""API: list all data sources for a collection."""
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
@router.delete("/{collection_id}/sources/{source_id}/api")
async def remove_data_source(request: Request, collection_id: int, source_id: int):
def remove_data_source(request: Request, collection_id: int, source_id: int):
"""API: remove a data source from a collection."""
with get_conn() as conn:
existing = conn.execute(
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
@router.post("/{collection_id}/toggle-inline/api")
async def toggle_inline(request: Request, collection_id: int):
def toggle_inline(request: Request, collection_id: int):
"""API: toggle a collection between full-page and inline mode."""
with get_conn() as conn:
coll = conn.execute(
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
@router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int):
def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn:
rows = conn.execute(
@@ -1819,7 +1820,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency."""
with get_conn() as conn:
existing = conn.execute(
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
Widgets live in ``collection_dashboards.layout_json`` as
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
@router.get("/{collection_id}", response_class=HTMLResponse)
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int):
def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
+49 -49
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
@@ -251,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
@@ -278,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
@@ -307,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
@@ -433,21 +434,24 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
def accounts_page(request: Request):
"""Account management panel."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
users = conn.execute(
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
).fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
def help_page(request: Request):
"""Comprehensive help & documentation page."""
from app.templating import ENV
env = ENV
@@ -652,7 +656,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from app.templating import ENV
env = ENV
@@ -727,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
def generate_token(request: Request):
import secrets
uid = _require_user_id(request)
token = secrets.token_hex(32)
@@ -741,7 +745,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
@@ -830,7 +834,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from app.templating import ENV
env = ENV
@@ -847,7 +851,7 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request):
def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
@@ -967,7 +971,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int):
def list_members(request: Request, ws_id: int):
"""List all members of a workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -1018,7 +1022,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int):
def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace."""
with get_conn() as conn:
conn.execute(
@@ -1032,7 +1036,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None):
def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
@@ -1080,7 +1084,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None):
def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
@@ -1105,7 +1109,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
@router.get("/api/local-workspace/page-content/{page_id:int}")
async def get_page_content(page_id: int):
def get_page_content(page_id: int):
"""Return the raw content of a page (for preview)."""
with get_conn() as conn:
row = conn.execute(
@@ -1143,9 +1147,8 @@ def _file_page_disk_path(page: dict):
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
import os as _os
from pathlib import Path
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
root = Path(settings.data_dir).resolve()
full = (root / rel).resolve()
try:
full.relative_to(root)
@@ -1171,7 +1174,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@router.get("/api/pages/{page_id}/download")
async def download_page_file(request: Request, page_id: int):
def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id)
with get_conn() as conn:
@@ -1194,7 +1197,7 @@ async def download_page_file(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/file-content")
async def page_file_content(request: Request, page_id: int):
def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
@@ -1222,7 +1225,7 @@ async def page_file_content(request: Request, page_id: int):
@router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int):
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
@@ -1390,7 +1393,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the
@@ -1480,7 +1483,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int):
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime
with get_conn() as conn:
@@ -1493,7 +1496,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore")
async def restore_local_workspace_item(request: Request, item_id: int):
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
with get_conn() as conn:
conn.execute(
@@ -1505,12 +1508,11 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
import os
from pathlib import Path
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
root = Path(settings.data_dir)
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1568,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1656,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1774,7 +1774,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request):
def workspaces_page(request: Request):
"""Workspaces list page."""
from app.templating import ENV
env = ENV
@@ -1791,7 +1791,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces")
async def list_workspaces(request: Request):
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -1855,7 +1855,7 @@ async def rename_workspace(request: Request, ws_id: int):
@router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int):
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
@@ -1866,7 +1866,7 @@ async def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int):
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1877,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
def app_settings_page(request: Request):
"""Settings & configuration page."""
from app.templating import ENV
env = ENV
@@ -1923,7 +1923,7 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from pathlib import Path
@@ -1942,7 +1942,7 @@ async def serve_avatar_file(filename: str):
@router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int):
def get_avatar(user_id: int):
"""Redirect to the user's avatar."""
with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -2002,7 +2002,7 @@ async def update_tag_global(tag_id: int, request: Request):
@router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int, request: Request):
def delete_tag_global(tag_id: int, request: Request):
"""Delete a tag — only if owned by user."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2013,7 +2013,7 @@ async def delete_tag_global(tag_id: int, request: Request):
@router.get("/api/settings/tags/all")
async def list_all_tags_global(request: Request):
def list_all_tags_global(request: Request):
"""List current user's tags with counts."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2032,7 +2032,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
def list_tags(request: Request):
"""List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2055,7 +2055,7 @@ async def list_tags(request: Request):
@router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int):
def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
@@ -2104,7 +2104,7 @@ async def add_item_tag(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int):
def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item."""
with get_conn() as conn:
conn.execute(
@@ -2116,7 +2116,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""):
def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2198,7 +2198,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
@@ -2260,7 +2260,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str):
def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV
@@ -2525,7 +2525,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content")
async def api_page_content(page_id: int):
def api_page_content(page_id: int):
"""Get page content for side peek preview."""
with get_conn() as conn:
row = conn.execute(
@@ -2563,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
@router.post("/api/pages/{page_id:int}/trash")
async def api_trash_page(page_id: int):
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
with get_conn() as conn:
conn.execute(
@@ -2641,7 +2641,7 @@ async def api_convert_to_database(page_id: int, request: Request):
@router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int):
def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view."""
with get_conn() as conn:
coll = conn.execute(
+4 -4
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
router = APIRouter(tags=["emojis"])
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _active_ws(request: Request) -> int:
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
@router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request):
def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis."""
ws_id = _active_ws(request)
with get_conn() as conn:
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
@router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int):
def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request)
with get_conn() as conn:
+4 -4
View File
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
def export_html(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
def export_site(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
+5 -13
View File
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
@@ -170,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
# ── Account linking ──
@router.get("/status")
async def gitea_status(request: Request):
def gitea_status(request: Request):
"""Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
@@ -178,7 +170,7 @@ async def gitea_status(request: Request):
@router.delete("/disconnect")
async def disconnect_gitea(request: Request):
def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -194,7 +186,7 @@ async def disconnect_gitea(request: Request):
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request):
def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -233,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -280,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
+2 -2
View File
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status")
async def github_status(request: Request):
def github_status(request: Request):
"""Check if the current user has GitHub linked."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -22,7 +22,7 @@ async def github_status(request: Request):
@router.delete("/disconnect")
async def disconnect_github(request: Request):
def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
+2 -2
View File
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
@router.get("/api/v2/agent-policies")
async def list_policies(request: Request):
def list_policies(request: Request):
_owner_or_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
@router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request):
def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
with get_conn() as conn:
+5 -5
View File
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
@router.get("/sources")
async def import_sources(request: Request):
def import_sources(request: Request):
"""List every available importer for the UI source picker."""
return {"sources": list_sources()}
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
@router.post("/relations/resolve")
async def import_resolve_relations(request: Request):
def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request)
with get_conn() as conn:
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
@router.get("/jobs")
async def import_jobs(request: Request):
def import_jobs(request: Request):
return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}")
async def import_job(job_id: str):
def import_job(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
@router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str):
def import_job_report(job_id: str):
"""Download a job's import report as JSON."""
job = get_job(job_id)
if not job:
+10 -10
View File
@@ -157,7 +157,7 @@ BASE_SELECT = (
@router.get("/recents")
async def library_recents(
def library_recents(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -191,7 +191,7 @@ async def library_recents(
@router.get("/favorites")
async def library_favorites(
def library_favorites(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -221,7 +221,7 @@ async def library_favorites(
@router.get("/shared")
async def library_shared(
def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -292,7 +292,7 @@ async def library_shared(
@router.get("/published")
async def library_published(
def library_published(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -317,7 +317,7 @@ async def library_published(
@router.get("/private")
async def library_private(
def library_private(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -342,7 +342,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request):
def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
with get_conn() as conn:
@@ -410,7 +410,7 @@ async def library_local_workspace_children(item_id: int, request: Request):
@router.get("/children/{page_id:int}")
async def library_children(page_id: int, request: Request):
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -426,7 +426,7 @@ async def library_children(page_id: int, request: Request):
@router.get("/repository")
async def library_repository(
def library_repository(
request: Request,
gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""),
@@ -450,7 +450,7 @@ async def library_repository(
@router.get("/local-workspace")
async def library_local_workspace(
def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
@@ -535,7 +535,7 @@ def _format_size(size_bytes):
@router.get("/workspace")
async def library_workspace(
def library_workspace(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
+3 -3
View File
@@ -75,13 +75,13 @@ async def create_link(request: Request):
@router.get("/api/v2/calendar-links")
async def get_links(request: Request):
def get_links(request: Request):
user = _auth_user(request)
return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}")
async def remove_link(link_id: int, request: Request):
def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
# ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request):
def freebusy(collection_id: int, request: Request):
_auth_user(request)
qp = request.query_params
try:
+2 -2
View File
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
@router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
@router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def get_notes(request: Request, owner: str, repo: str):
def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
+5 -5
View File
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
@router.get("")
async def list_notifications(request: Request, limit: int = 50):
def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first."""
user = _current_user(request)
with get_conn() as conn:
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
@router.get("/unread-count")
async def unread_count(request: Request):
def unread_count(request: Request):
"""Unread count for the topbar badge."""
user = _current_user(request)
with get_conn() as conn:
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
@router.get("/prefs")
async def get_prefs(request: Request):
def get_prefs(request: Request):
"""Return the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
@router.get("/timezone")
async def get_timezone(request: Request):
def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
_current_user(request)
q = (q or "").strip()
+1 -1
View File
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
@router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request):
def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+14 -14
View File
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
@router.get("/pages/{page_id}/permissions")
async def list_page_permissions(page_id: int, request: Request):
def list_page_permissions(page_id: int, request: Request):
"""List explicit page grants + the caller's effective role."""
pm = _pm(request)
if not pm.can_view_page(page_id):
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
@router.get("/pages/{page_id}/permissions/mine")
async def my_page_permission(page_id: int, request: Request):
def my_page_permission(page_id: int, request: Request):
"""Effective role of the current user on a page (UI gating)."""
pm = _pm(request)
if not pm.can_view_page(page_id):
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
@router.delete("/pages/{page_id}/permissions/{perm_id}")
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions")
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
@router.get("/collections/{collection_id}/permissions")
async def list_collection_permissions(collection_id: int, request: Request):
def list_collection_permissions(collection_id: int, request: Request):
pm = _pm(request)
if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found")
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage its permissions")
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/visible")
async def visible_properties(collection_id: int, request: Request):
def visible_properties(collection_id: int, request: Request):
"""Split property ids into visible / hidden for the current user."""
pm = _pm(request)
if not pm.can_view_collection(collection_id):
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
def list_property_permissions(collection_id: int, property_id: int, request: Request):
pm = _pm(request)
if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found")
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
async def revoke_property_permission(collection_id: int, property_id: int,
def revoke_property_permission(collection_id: int, property_id: int,
perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
@router.get("/groups")
async def list_groups(request: Request, workspace_id: int | None = None):
def list_groups(request: Request, workspace_id: int | None = None):
user = _require_user(request)
pm = PermissionManager(user["id"])
return {"groups": pm.get_groups_for_workspace(workspace_id)}
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
@router.delete("/groups/{group_id}")
async def delete_group(group_id: int, request: Request):
def delete_group(group_id: int, request: Request):
pm = _pm(request)
with get_conn() as conn:
row = conn.execute(
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
@router.get("/groups/{group_id}/members")
async def list_group_members(group_id: int, request: Request):
def list_group_members(group_id: int, request: Request):
user = _require_user(request)
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
@router.delete("/groups/{group_id}/members/{user_id}")
async def remove_group_member(group_id: int, user_id: int, request: Request):
def remove_group_member(group_id: int, user_id: int, request: Request):
pm = _pm(request)
with get_conn() as conn:
row = conn.execute(
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
@router.get("/users")
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
"""Workspace members (+ admins) for the grant pickers."""
_require_user(request)
q = (q or "").strip().lower()
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
@router.get("/audit/permissions")
async def permission_audit(request: Request, limit: int = 100):
def permission_audit(request: Request, limit: int = 100):
"""Full permission change history — workspace owner/admin only."""
user = _require_user(request)
uid = user["id"]
+1 -1
View File
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
@router.get("")
async def list_projects(request: Request):
def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)}
+6 -6
View File
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token")
async def generate_token(request: Request):
def generate_token(request: Request):
"""Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
@router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request):
def public_list_collections(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int):
def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int):
def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int):
def public_get_page(request: Request, page_id: int):
with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p:
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request):
def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token)."""
with get_conn() as conn:
pages = conn.execute(
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
@router.get("/api/realtime/stats")
async def realtime_stats(request: Request):
def realtime_stats(request: Request):
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
Réservé aux utilisateurs authentifiés (données d'activité internes).
+7 -7
View File
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
# ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users")
async def scim_list(request: Request):
def scim_list(request: Request):
_scim_guard(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
@router.get("/scim/v2/Users/{user_id}")
async def scim_get(user_id: str, request: Request):
def scim_get(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
@router.delete("/scim/v2/Users/{user_id}")
async def scim_delete(user_id: str, request: Request):
def scim_delete(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
@router.get("/api/v2/scim/tokens")
async def list_scim_tokens(request: Request):
def list_scim_tokens(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
@router.delete("/api/v2/scim/tokens/{token_id}")
async def revoke_scim_token(token_id: int, request: Request):
def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
# ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims")
async def list_domains(request: Request):
def list_domains(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
@router.delete("/api/v2/domain-claims/{domain_id}")
async def delete_domain(domain_id: int, request: Request):
def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
+1 -1
View File
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
@router.get("/api/search")
async def search(request: Request, q: str = Query(default="")):
def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
+2 -2
View File
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
@router.get("/api/v2/search/hybrid")
async def hybrid(request: Request):
def hybrid(request: Request):
user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q:
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
@router.get("/api/v2/search/index-status")
async def index_status(request: Request):
def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request)
with get_conn() as conn:
+4 -4
View File
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
@router.get("/tokens")
async def list_tokens(request: Request):
def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -63,7 +63,7 @@ async def create_token(request: Request):
@router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request):
def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
@router.get("/sessions")
async def list_sessions(request: Request):
def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
@router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request):
def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request)
with get_conn() as conn:
+7 -60
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
@@ -11,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event as _fire_event
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
return user
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ──
@@ -213,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
_require_auth(request)
@@ -247,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
@router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request):
def list_shares(page_id: int, request: Request):
"""Get all shares for a page."""
_require_auth(request)
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page")
slug, _title = publish(page_id)
await fire_published(page_id, slug)
return {
"page_id": page_id,
"is_published": True,
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page")
unpublish(page_id)
await fire_unpublished(page_id)
return {
"page_id": page_id,
"is_published": False,
+1 -1
View File
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
@router.get("/config")
async def get_sidebar_config(request: Request):
def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config."""
user = _get_user(request)
with get_conn() as conn:
+11 -11
View File
@@ -315,7 +315,7 @@ async def create_site(request: Request):
@router.get("/api/v2/sites")
async def list_sites(request: Request):
def list_sites(request: Request):
user = _auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
@router.get("/api/v2/sites/{site_id}")
async def get_site(site_id: int, request: Request):
def get_site(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}")
async def delete_site(site_id: int, request: Request):
def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/pages")
async def list_site_pages(site_id: int, request: Request):
def list_site_pages(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async def remove_site_page(site_id: int, page_id: int, request: Request):
def remove_site_page(site_id: int, page_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/stats")
async def site_stats(site_id: int, request: Request, days: int = 30):
def site_stats(site_id: int, request: Request, days: int = 30):
user = _auth_user(request)
days = max(1, min(int(days or 30), 365))
with get_conn() as conn:
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
@router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str):
def public_site_home(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", ""))
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async def site_sitemap(request: Request, slug: str):
def site_sitemap(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
if not site or _site_expired(site) or site.get("password_hash"):
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async def public_site_page(request: Request, slug: str, page_ref: str):
def public_site_page(request: Request, slug: str, page_ref: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site:
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
# ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request):
def get_form_config(collection_id: int, request: Request):
_auth_user(request)
with get_conn() as conn:
info = _form_config(conn, collection_id)
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
@router.get("/f/{token}", response_class=HTMLResponse)
async def public_form(request: Request, token: str):
def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1"
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections").fetchone()
+3 -3
View File
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
@router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
@router.get("/api/v2/sso/providers")
async def sso_providers(request: Request):
def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error()
if not cfg:
+1 -1
View File
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None),
def sync_status(request: Request, workspace_id: int = Query(default=None),
authorization: str | None = Header(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request, authorization, required_scope="read")
+1 -1
View File
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
# ── HTML: /extensions download page ──
@router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request):
def extensions_page(request: Request):
from app.routers.dashboard import _sidebar_data
from app.templating import ENV
+3 -3
View File
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
@router.post("/register/begin")
async def register_begin(request: Request):
def register_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
@router.get("/keys")
async def list_keys(request: Request):
def list_keys(request: Request):
user = _session_user(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
@router.delete("/keys/{key_id}")
async def delete_key(key_id: int, request: Request):
def delete_key(key_id: int, request: Request):
user = _session_user(request)
with get_conn() as conn:
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
+17 -17
View File
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
# ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request):
def list_teamspaces(request: Request):
user = _user(request)
wid = request.query_params.get("workspace_id")
if wid:
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async def teamspace_page(teamspace_id: int, request: Request):
def teamspace_page(teamspace_id: int, request: Request):
"""Teamspace detail HTML page — sidebar entry point."""
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async def get_teamspace(teamspace_id: int, request: Request):
def get_teamspace(teamspace_id: int, request: Request):
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request):
def list_members(teamspace_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn:
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def remove_member(teamspace_id: int, member_id: int, request: Request):
def remove_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id):
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
# ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request):
def get_verification(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
return {"verification": wiki.verification(page_id)}
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request):
def unverify_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
if not wiki.unverify_page(page_id):
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/verified")
async def list_verified(request: Request):
def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request)
wid = _workspace_id(request)
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
# ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request):
def follow_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"])
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request):
def list_followers(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
ids = wiki.followers(page_id)
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request):
def list_reactions(comment_id: int, request: Request):
_user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request):
def list_guests(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
with get_conn() as conn:
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
@router.delete("/api/v2/wiki/guests/{share_id}")
async def revoke_guest(share_id: int, request: Request):
def revoke_guest(share_id: int, request: Request):
user = _user(request)
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
@router.get("/g/{token}", response_class=HTMLResponse)
async def guest_page(token: str, request: Request):
def guest_page(token: str, request: Request):
"""Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token)
if not share:
@@ -465,7 +465,7 @@ Editing is disabled.</div>
# ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views")
async def page_views(page_id: int, request: Request):
def page_views(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
# ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home")
async def wiki_home(request: Request):
def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request)
wid = _workspace_id(request)
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
@router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request):
def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request)
with get_conn() as conn:
+6 -6
View File
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
@router.get("/api/v2/workers")
async def list_workers(request: Request):
def list_workers(request: Request):
_auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
@router.get("/api/v2/workers/{worker_id}")
async def get_worker(worker_id: int, request: Request):
def get_worker(worker_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
@router.delete("/api/v2/workers/{worker_id}")
async def delete_worker(worker_id: int, request: Request):
def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request):
def worker_runs(worker_id: int, request: Request):
_auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn:
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
@router.post("/api/v2/workers/{worker_id}/fork")
async def fork_worker_endpoint(worker_id: int, request: Request):
def fork_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
out = worker_service.fork_worker(worker_id, user["id"])
audit_log(user, "worker.fork", "worker", worker_id, "", request)
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers-usage")
async def workers_usage(request: Request):
def workers_usage(request: Request):
user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id")
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
+19 -19
View File
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
# ── Members ──
@router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int):
def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int):
def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ── Comments ──
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
def list_comments(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
# ── Page History ──
@router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int):
def page_history(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
# ── Favorites ──
@router.get("/favorites")
async def list_favorites(request: Request):
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
@router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int):
def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
# ── Templates ──
@router.get("/templates/database")
async def list_db_templates(request: Request):
def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]}
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
@router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int):
def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int):
def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int):
def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int):
def export_csv(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
# ── Webhooks Outbound Management ──
@router.get("/webhooks")
async def list_webhooks(request: Request):
def list_webhooks(request: Request):
_require_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int):
def delete_webhook(request: Request, wh_id: int):
_require_admin(request)
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ──
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
+5 -1
View File
@@ -214,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
return user
def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read"
# Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user
+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)}
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):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
+2 -2
View File
@@ -17,12 +17,12 @@ from __future__ import annotations
import io
import json
import os
import re
import zipfile
from pathlib import Path
from urllib.parse import quote
from app.config import settings
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
def _data_root() -> Path:
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _file_meta(page: dict) -> dict:
+4 -1
View File
@@ -35,7 +35,10 @@ class GiteaClient:
return None
def _set_cache(self, key: str, value: Any) -> None:
self._cache[key] = (datetime.now() + self._ttl, value)
now = datetime.now()
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
self._cache[key] = (now + self._ttl, value)
# ── repos ──
-6
View File
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
+2 -2
View File
@@ -10,11 +10,11 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from app.config import settings
from app.db import get_conn
from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _safe_filename(name: str) -> str:
+2 -1
View File
@@ -16,6 +16,7 @@ import shutil
import subprocess
from pathlib import Path
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
def meetings_dir() -> Path:
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
root = Path(settings.data_dir)
d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True)
return d
-30
View File
@@ -247,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
return None
def apply_auto_properties(
@@ -291,28 +284,5 @@ def apply_auto_properties(
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+91
View File
@@ -0,0 +1,91 @@
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
Les trois surfaces divergeaient avant cette passe :
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
"""
from __future__ import annotations
import logging
import re
import secrets
import unicodedata
from fastapi import HTTPException
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
def slugify(title: str) -> str:
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug
def _unique_slug(conn, page_id: int, title: str) -> str:
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
slug, counter = base, 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base}-{counter}"
counter += 1
return slug
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
with get_conn() as conn:
page = conn.execute(
"SELECT id, title FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
def unpublish(page_id: int) -> None:
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
async def fire_published(page_id: int, slug: str) -> None:
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish page.published")
async def fire_unpublished(page_id: int) -> None:
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("publish page.unpublished")
-3
View File
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
-11
View File
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
-13
View File
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
def verify_device_token(device_id: str, token: str) -> dict | None:
"""Verify a device token, returns device row or None."""
thash = _hash_token(token)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
(device_id, thash),
).fetchone()
if row:
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
return dict(row)
return None
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
-5
View File
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+1 -1
View File
@@ -15,7 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button>
{% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div>
</header>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -2
View File
@@ -10,7 +10,7 @@
############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
@@ -89,7 +89,7 @@
})();
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
</span>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+1 -1
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
+2 -2
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data">
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }}
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true;
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() {
return {
profile: { full_name: '', email: '' },
+2 -2
View File
@@ -204,7 +204,7 @@
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
+12 -9
View File
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
@@ -2129,7 +2132,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2207,7 +2210,7 @@
</div>
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2377,8 +2380,8 @@
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script data-cfasync="false">
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -98,7 +98,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+1 -1
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
+1 -1
View File
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() {
return {
sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
+1 -1
View File
@@ -456,7 +456,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() {
return {
tab: 'recents',
+2 -2
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
return {
@@ -2512,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
+1 -1
View File
@@ -1191,7 +1191,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() {
return {
activeSection: 'account',
+1 -1
View File
@@ -99,7 +99,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function tableView() {
return {
sortField: '',
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function teamLoad() {
return {};
}
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() {
return {
search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() {
return {
builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() {
return {
workspaces: [],
+21
View File
@@ -6,9 +6,30 @@ les `|safe` du codebase étaient des no-op.
"""
from __future__ import annotations
import pathlib
from contextvars import ContextVar
from jinja2 import Environment, FileSystemLoader, select_autoescape
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
)
# A40 : version des assets statiques première main, lue une seule fois (le
# fichier VERSION fait foi ; repli "dev" si l'image ne le contient pas).
try:
ASSET_VERSION = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text(
encoding="utf-8"
).strip()
except OSError: # pragma: no cover
ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
+1 -1
View File
@@ -772,7 +772,7 @@ EVENTS = [
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version` = VERSION courante).
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
+518 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.3.9"
"version": "7.10.0"
},
"paths": {
"/auth/register": {
@@ -16725,6 +16725,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -16974,6 +16985,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17123,6 +17145,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17233,6 +17266,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17397,6 +17441,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17462,6 +17517,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17617,6 +17683,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17727,6 +17804,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17837,6 +17925,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18003,6 +18102,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18178,6 +18288,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18288,6 +18409,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18454,6 +18586,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18564,6 +18707,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18674,6 +18828,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18849,6 +19014,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18905,6 +19081,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19015,6 +19202,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19062,6 +19260,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19109,6 +19318,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19219,6 +19439,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19275,6 +19506,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19385,6 +19627,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19495,6 +19748,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19551,6 +19815,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19715,6 +19990,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19771,6 +20057,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19881,6 +20178,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20125,6 +20433,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20217,6 +20536,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20365,6 +20695,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20421,6 +20762,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20531,6 +20883,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20753,6 +21116,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20809,6 +21183,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20919,6 +21304,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21085,6 +21481,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21195,6 +21602,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21251,6 +21669,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21361,6 +21790,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21592,6 +22032,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21648,6 +22099,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21861,6 +22323,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22367,6 +22840,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22618,6 +23102,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22674,6 +23169,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22961,6 +23467,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
-488
View File
@@ -390,28 +390,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.um-version { font-size: 11px; color: var(--text-dim); padding: 4px 14px 2px; }
.um-section-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; padding: 4px 14px 2px; }
.sidebar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.sidebar-action-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 14px;
transition: background var(--transition);
}
.sidebar-action-btn:hover {
background: var(--bg-hover);
}
/* Navigation items */
.sidebar-nav {
display: flex;
@@ -421,27 +399,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
margin-bottom: var(--space-xs);
}
.sidebar-nav-item {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
transition: background var(--transition);
white-space: nowrap;
}
.sidebar-nav-item:hover {
background: var(--bg-hover);
}
.sidebar-nav-item.active {
background: var(--bg-active);
}
.sidebar-nav-item .nav-icon {
font-size: 16px;
width: 20px;
@@ -877,7 +834,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
}
.nav-icon-inline { margin-right: 6px; vertical-align: middle; display: inline-flex; align-items: center; }
.nav-icon-inline svg { vertical-align: middle; }
.empty-state-icon { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
.sidebar-item:active .page-icon {
cursor: grabbing;
@@ -967,60 +923,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
padding: var(--space-sm) var(--space-md);
}
.sidebar-invite {
background: var(--bg-hover);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-sm);
position: relative;
}
.sidebar-invite-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.sidebar-invite-desc {
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-invite-close {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 12px;
}
.sidebar-new-chat {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--space-md);
background: var(--bg-hover);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
width: 100%;
transition: background var(--transition);
}
.sidebar-new-chat:hover {
background: var(--bg-tertiary);
}
.sidebar-new-chat .shortcut {
font-size: 12px;
color: var(--text-secondary);
@@ -1137,35 +1039,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
flex-shrink: 0;
}
.topbar-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
min-width: 0;
}
.topbar-page-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 14px;
color: var(--text-primary);
}
.topbar-page-title .page-icon {
font-size: 16px;
}
.topbar-page-status {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.topbar-right {
display: flex;
align-items: center;
@@ -1201,14 +1078,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
background: var(--bg-hover);
}
.topbar-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--text-secondary);
}
/* ── Unified Header Breadcrumb ── */
.header-breadcrumb {
display: flex;
@@ -1406,16 +1275,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover);
}
.option-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.topbar-breadcrumb a:hover {
color: var(--text-primary);
}
/* Content area */
.content-area {
flex: 1;
@@ -1424,11 +1283,6 @@ button.breadcrumb-current { max-width: 260px; }
padding: 0;
}
/* Page header */
.page-header {
padding: var(--space-xl) var(--space-xl) var(--space-md);
}
.page-cover {
width: 100%;
height: 160px;
@@ -1755,22 +1609,6 @@ button.breadcrumb-current { max-width: 260px; }
font-weight: 500;
}
/* Card colors by status */
.card-status-todo {
background: var(--bg-secondary);
border-left: 3px solid var(--gray);
}
.card-status-progress {
background: var(--blue-bg);
border-left: 3px solid var(--blue);
}
.card-status-complete {
background: var(--green-bg);
border-left: 3px solid var(--green);
}
/* New card button */
.kanban-new-card {
display: flex;
@@ -2035,31 +1873,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--red);
}
/* Panel dropdown helpers */
.dropdown-search {
padding: var(--space-sm);
}
.dropdown-search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px var(--space-sm);
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.dropdown-search input:focus {
border-color: var(--blue);
}
.dropdown-option-list {
max-height: 240px;
overflow-y: auto;
}
.dropdown-option {
display: flex;
align-items: center;
@@ -2102,26 +1915,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%;
}
.dropdown-footer {
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border);
}
.dropdown-footer button {
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.dropdown-footer button:hover {
color: var(--text-primary);
}
/* Sort panel */
.sort-panel {
position: relative;
}
.sort-list {
padding: var(--space-xs) 0;
}
@@ -2228,32 +2021,14 @@ button.breadcrumb-current { max-width: 260px; }
UTILITIES
═══════════════════════════════════════════════════════════ */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.text-dim { color: var(--text-secondary); }
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.font-semibold { font-weight: 600; }
/* Library table */
.library-table-header {
display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; font-weight: 600; color: var(--text-dim);
}
.library-table-row {
display: flex; align-items: center; padding: 8px 12px; text-decoration: none;
color: var(--text-primary); border-radius: 6px; transition: background 0.15s;
}
.library-table-row:hover { background: var(--bg-hover); }
.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; }
.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); }
.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); }
.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); }
.lib-empty p { font-size: 13px; }
@@ -2282,8 +2057,6 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px; color: var(--text-secondary); transition: background 0.15s;
}
.trash-item-btn:hover { background: var(--bg-tertiary); }
.ml-auto { margin-left: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
@@ -2362,11 +2135,6 @@ button.breadcrumb-current { max-width: 260px; }
text-align: center;
}
.blocks-empty-hint {
color: var(--text-dim);
font-size: 15px;
}
/* Block wrapper */
.block-wrapper {
position: relative;
@@ -2502,7 +2270,6 @@ button.breadcrumb-current { max-width: 260px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
.ftable-hdr-toggle input { cursor:pointer; }
/* Block actions (+ button) */
.block-actions {
position: absolute;
@@ -2534,23 +2301,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-primary);
}
.block-drag-btn {
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 11px;
cursor: grab;
transition: background 0.1s;
}
.block-drag-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Block content (base) */
.block-content {
flex: 1;
@@ -2570,36 +2320,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: normal;
}
/* Headings */
.block-h1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
padding: 8px 2px 4px;
}
.block-h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
padding: 6px 2px 3px;
}
.block-h3 {
font-size: 20px;
font-weight: 600;
line-height: 1.3;
padding: 4px 2px 2px;
}
.block-h4 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
padding: 4px 2px 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
/* Bulleted list */
.block-bullet {
padding-left: 18px;
@@ -2685,13 +2405,6 @@ button.breadcrumb-current { max-width: 260px; }
word-break: break-word;
}
/* Quote */
.block-quote {
border-left: 3px solid var(--text-primary);
padding: 4px 14px;
margin: 4px 0;
}
/* Code block */
.block-code {
background: var(--bg-secondary);
@@ -2835,11 +2548,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: italic;
padding: 12px 16px;
}
/* Math block */
.block-math {
margin: 8px 0;
}
.math-display {
min-height: 40px;
padding: 10px 14px;
@@ -2953,28 +2661,6 @@ button.breadcrumb-current { max-width: 260px; }
display: none;
}
/* Image block */
.block-image-wrapper {
margin: 8px 0;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: var(--bg-secondary);
border: 1px dashed var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s;
}
.image-placeholder:hover {
background: var(--bg-hover);
}
/* SLASH COMMAND MENU */
.slash-menu {
position: fixed;
@@ -2990,45 +2676,6 @@ button.breadcrumb-current { max-width: 260px; }
flex-direction: column;
}
.slash-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.slash-search input {
width: 100%;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.slash-search input:focus {
border-color: var(--blue);
}
.slash-groups {
overflow-y: auto;
padding: 4px 0;
}
.slash-group {
padding: 0;
}
.slash-group + .slash-group {
border-top: 1px solid var(--border);
}
.slash-group-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slash-item {
display: flex;
align-items: center;
@@ -3058,27 +2705,10 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 4px;
flex-shrink: 0;
}
.slash-item-info {
flex: 1;
display: flex;
flex-direction: column;
}
.slash-item-name {
font-weight: 500;
font-size: 14px;
}
.slash-item-desc {
font-size: 12px;
color: var(--text-dim);
}
.slash-item-shortcut {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
flex-shrink: 0;
}
/* FORMAT TOOLBAR (FLOATING) */
.format-toolbar {
@@ -3110,13 +2740,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover);
}
.format-separator {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
/* EDITOR STATUSBAR */
.editor-statusbar {
position: fixed;
@@ -3266,15 +2889,6 @@ button.breadcrumb-current { max-width: 260px; }
.fm-manual { margin-top: 8px; }
.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
.fm-transcript { max-height: 40vh; overflow-y: auto; }
/* ═══════════ Page Editor — Top Bar ═══════════ */
.page-topbar {
display: flex; align-items: center; justify-content: flex-end;
padding: 6px 0; min-height: 36px;
}
.page-topbar-right {
display: flex; align-items: center; gap: 4px; position: relative;
}
.topbar-edited {
font-size: 12px; color: var(--text-dim); margin-right: 8px;
}
@@ -3435,12 +3049,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: var(--radius-lg); box-shadow: var(--shadow-popover);
z-index: 999; padding: 4px 0;
}
.sd-access-option {
padding: 8px 14px; font-size: 13px; color: var(--text-primary);
cursor: pointer; display: flex; align-items: center; gap: 8px;
}
.sd-access-option:hover { background: var(--bg-hover); }
.sd-access-option.active { background: rgba(35,131,226,.15); }
/* Permissions */
.sd-select-sm {
@@ -3602,22 +3210,6 @@ button.breadcrumb-current { max-width: 260px; }
.toggle-switch.sm .toggle-slider:before { height: 14px; width: 14px; }
.toggle-switch.sm input:checked + .toggle-slider:before { transform: translateX(14px); }
/* ═══════════ Empty Page Toolbar ═══════════ */
.empty-page-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 12px 0; flex-wrap: wrap;
}
.empty-toolbar-label {
font-size: 13px; color: var(--text-dim); margin-right: 6px;
}
.empty-toolbar-item {
padding: 6px 12px; background: var(--bg-secondary);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text-primary); font-size: 13px; cursor: pointer;
white-space: nowrap;
}
.empty-toolbar-item:hover { background: var(--bg-hover); border-color: var(--text-dim); }
/* ═══════════════════════════════════════════════════════════
RESPONSIVE — MOBILE-FIRST DESIGN SYSTEM
Breakpoints: ≤480px phone | ≤768px tablet | ≤1024px small desktop
@@ -3654,20 +3246,6 @@ button.breadcrumb-current { max-width: 260px; }
opacity: 1; pointer-events: auto;
}
/* ── Sidebar Back Button (mobile) ── */
.sidebar-back-btn {
display: none;
width: 36px; height: 36px;
border-radius: var(--radius-sm);
align-items: center; justify-content: center;
color: var(--text-primary); font-size: 18px;
cursor: pointer; transition: background var(--transition);
}
.sidebar-back-btn:hover { background: var(--bg-hover); }
/* ── Topbar mobile spacer ── */
.topbar-mobile-spacer { flex: 1; min-width: 0; }
/* ═══════════════════════════════════════════════════════════
TABLET (≤1024px)
═══════════════════════════════════════════════════════════ */
@@ -3764,7 +3342,6 @@ button.breadcrumb-current { max-width: 260px; }
.topbar-right span.text-xs { display: none; }
.topbar-breadcrumb { font-size: 13px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Content ── */
.content-area { overflow-y: auto; }
.page-cover-area { padding: 24px 16px 8px; }
@@ -3909,15 +3486,6 @@ button.breadcrumb-current { max-width: 260px; }
.btn-new { height: 36px; padding: 8px 12px !important; }
}
/* ══════════ Uncollapse sidebar button ══════════ */
.sidebar-uncollapse-btn {
position: fixed; top: 60px; left: 12px; z-index: 1001;
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-secondary); cursor: pointer;
}
.sidebar-uncollapse-btn:hover { background: var(--bg-hover); color: var(--text); }
/* ═══════════════════════════════════════════════════════════
SHARED COMPONENTS (migrated from design-tokens.css)
═══════════════════════════════════════════════════════════ */
@@ -3936,22 +3504,6 @@ button.breadcrumb-current { max-width: 260px; }
50% { opacity: .5; }
}
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
pointer-events: none;
}
.skeleton-card .skeleton-line {
height: 14px;
margin-bottom: 10px;
border-radius: 4px;
}
.skeleton-card .skeleton-line:last-child { margin-bottom: 0; }
.skeleton-file {
padding: 16px;
border: 1px solid var(--border);
@@ -3961,20 +3513,6 @@ button.breadcrumb-current { max-width: 260px; }
overflow: hidden;
}
.skeleton-line {
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-table { overflow: hidden; }
.skeleton-table .skeleton-line {
height: 40px;
margin-bottom: 2px;
border-radius: 0;
}
/* Toast */
.toast-container {
position: fixed;
@@ -4005,16 +3543,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--toast-success-text);
}
.toast-error {
background: var(--toast-error-bg);
color: var(--toast-error-text);
}
.toast-info {
background: var(--toast-info-bg);
color: var(--toast-info-text);
}
@keyframes toast-slide-in {
from {
opacity: 0;
@@ -4132,21 +3660,6 @@ button.breadcrumb-current { max-width: 260px; }
box-shadow: 0 0 0 2px rgba(35,131,226,.15);
}
.settings-select {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 6px 10px;
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 140px;
font-family: var(--font-sans);
}
.settings-select:focus { border-color: var(--accent); }
/* Dialog */
.dialog-overlay {
position: fixed;
@@ -4420,7 +3933,6 @@ button.breadcrumb-current { max-width: 260px; }
.db-row:hover { background: var(--bg-hover); }
.db-row-new { border-bottom: 1px solid var(--border); }
.db-row-empty { height: 32px; }
.db-row-selected { background: var(--bg-active); }
.db-td {
padding: 3px 8px;
+205
View File
@@ -65,6 +65,211 @@ def test_rate_limit_key_and_prune():
assert "old" not in mw._store and "fresh" in mw._store
def test_cors_no_star(client):
"""A37 : plus de `*` — origine refusée n'a pas d'ACAO, origine autorisée oui."""
r = client.get("/api/health", headers={"Origin": "https://evil.example"})
assert "access-control-allow-origin" not in r.headers
r2 = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
assert r2.headers.get("access-control-allow-origin") == "http://localhost:8080"
def test_asset_version_single_source():
"""A40 : une seule source de version d'assets = le fichier VERSION."""
import re as _re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
version = (root / "VERSION").read_text(encoding="utf-8").strip()
from app.templating import ASSET_VERSION, ENV
assert ASSET_VERSION == version
assert ENV.globals["asset_version"] == version
src = (root / "app/templates/base.html").read_text(encoding="utf-8")
assert "app.css?v={{ asset_version }}" in src
assert "app.js?v={{ asset_version }}" in src
# plus aucun littéral de version première main dans les templates
literals = _re.findall(
r"(?:app|design-tokens|components|offline|flowdeck)\.(?:css|js)\?v=\d", src
)
assert literals == [], literals
def test_publish_service_shared_and_safe(client):
"""A29 : les 3 routers déléguent — 404 sur page absente, slug unique,
dépublication qui ne touche pas aux partages manuels."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, share_mode) "
"VALUES (1, 'Publie moi', 'contenu', 'markdown', 'anyone')",
)
pid = cur.lastrowid
conn.commit()
try:
r = client.post(f"/api/pages/{pid}/publish")
assert r.status_code == 200, r.text
slug = r.json()["publish_slug"]
assert slug # slugify du titre
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 1 and row["publish_slug"] == slug
assert row["share_mode"] == "anyone" # intouché (share dialog propriétaire)
r2 = client.delete(f"/api/pages/{pid}/publish")
assert r2.status_code == 200
with get_conn() as conn:
row = conn.execute(
"SELECT is_published, publish_slug, share_mode FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["is_published"] == 0 and row["publish_slug"] == ""
assert row["share_mode"] == "anyone" # dépublier ne révoque pas le partage
# 404 sur page inexistante — les deux chemins passent par le service
assert client.post("/api/pages/999999/publish").status_code == 404
assert client.delete("/api/pages/999999/publish").status_code == 404
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_users_me_no_secret_columns(client):
"""A29-byproduct : GET /api/users/me (v1) ne doit plus renvoyer password_hash."""
r = client.get("/api/users/me")
assert r.status_code == 200, r.text
body = r.json()
assert "password_hash" not in body, list(body)
assert "locked_until" not in body and "login_attempts" not in body
assert body.get("login") # la réponse reste exploitable
def test_gitea_cache_evicts_expired():
"""A42 : les entrées expirées sortent du cache à chaque écriture."""
from datetime import datetime, timedelta
from app.services.gitea_client import GiteaClient
c = GiteaClient.__new__(GiteaClient) # sans appel réseau
c._cache = {}
c._ttl = timedelta(seconds=1)
c._set_cache("k", "v")
assert c._cached("k") == "v"
# expire l'entrée puis force une autre écriture → la précédente est évacuée
c._cache["k"] = (datetime.now() - timedelta(seconds=1), "v")
c._set_cache("k2", "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():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app