Compare commits

...
16 Commits
Author SHA1 Message Date
bruno cb47f5c7f4 fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
  produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
  PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
  non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
  (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
  proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
  après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
  (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
  le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
  loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
  CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
  naïf identique, warnings de dépréciation divisés : 2374 → 926)

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00
bruno ffa1fa89ab fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00
bruno 3ad2605c9e fix: A14 — fin du fallback « row admin » sur l'agent (v7.3.7)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 20m33s
FlowDeck CI / docker (push) Canceled after 0s
- `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur
  `SELECT id FROM users WHERE login='admin'`
- `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers`
  et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`)
  exigent une session admin : 401 sans session, 403 non-admin
- `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants
  dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le
  provider par défaut du produit (commentaire `ponytail:` pour la fermeture)
- +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK
2026-10-01 07:53:06 -04:00
bruno 1f705ce512 fix: A19 terminé — plus aucun préfixe cookie-auth exempt du CSRF (v7.3.6)
FlowDeck CI / test (push) Successful in 20m10s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
- 46 appels non-GET équipés de `X-CSRF-Token` (expression cookie en ligne,
  portée indifférente) : agent_panel (9), settings (12), local_workspace (15),
  gitea_workspace (4), workspace (2), workspaces (5), library (2), welcome (2 en v7.3.5)
- 5 derniers préfixes sortis d'`EXCLUDED_PATHS` : /api/workspace (+/api/workspaces),
  /api/local-workspace, /api/settings, /api/gitea, /api/agent
- il ne reste exempté que : Bearer (/api/webhook, /api/v1, /api/v2, /scim/v2),
  callbacks /auth/*, pages publiques (/s/, /f/), /api/csrf-token et /api/frontend-error
- vérif : `node --check` des <script> des 39 templates → 0 échec (avant et après)
- tests : `anon_csrf()` sur les 5 tests d'anonymat devenus CSRF-first
- suite **1026/1026** · `ruff check app tests` OK
2026-10-01 07:41:23 -04:00
bruno cf76e00f12 docs(roadmap): A19 — 49 fetch restants (compte exact) au lieu de 51
FlowDeck CI / docker (push) Successful in 1m48s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m14s
2026-09-30 23:38:28 -04:00
bruno 0861f1fdbf fix: A19 (partiel) — CSRF réservé aux vrais cas d'exemption (v7.3.5)
- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes
  n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS`
  (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`,
  `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`,
  `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`)
- `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header
  (`adminFetch` prouve que `/api/admin` était déjà couvert)
- reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`),
  `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch
- tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de
  route du 403 middleware — 4 tests d'anonymat ajustés
- suite **1026/1026** · `ruff check app tests` OK
2026-09-30 23:38:03 -04:00
bruno 72fcef2ba9 fix: A16 — ACL sur l'export et les pièces jointes (v7.3.4)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m5s
- `export.py` : `_load_page_or_404(request, page_id)` — 401 sans session,
  404 si `PermissionManager.can_view_page` refuse (les 4 formats d'export)
- `dashboard.py` : helper `_require_page_view` sur `GET /api/pages/{id}/download`
  et `GET /api/pages/{id}/file-content`
- +1 test de non-régression → suite **1026/1026**, `ruff check app tests` OK
2026-09-30 23:20:26 -04:00
bruno 5a537f5dc3 fix: A12–A24 — SSRF, auth routes legacy, uploads, N+1 et routes doublonnes (v7.3.3)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / test (push) Successful in 20m45s
- A12 — `og_fetcher` : GET sans `follow_redirects`, `_is_public_host` revérifié à
  chaque saut (max 5) ; `POST /board/api/og/metadata` → 400 sur hôte privé/loopback
- A13 — router automations sous `Depends(_require_session)` (CRUD, run,
  press-button) + `created_by` sans fallback ; action `webhook` validée par
  `_is_public_host` avant POST (SSRF)
- A15 — webhooks sortants : `_require_admin` sur GET/POST/DELETE + `_is_public_host`
  sur l'URL en création
- A17 — router legacy `/api` sous `Depends(_require_session_or_bearer)` (session ou
  Bearer `/api/v1`), allowlist explicite `/api/health` + `/api/frontend-error`
- A22 — les 2 uploads locales : session exigée (`_require_user_id`) + `validate_upload`
  branché (taille + extension) + `FLOWDECK_DATA_DIR` au lieu de `/data` codé en dur
- A23 — N+1 : COUNT→`GROUP BY` (dashboard), cards→`executemany` (board sync),
  duplicata de propriétés→`executemany` + remap des ids par SELECT (collections)
- A24 — 2 routes écrasées supprimées : `GET /api/projects` (api.py) et
  `GET /workspace` (workspace.py) + test « aucun doublon méthode+chemin »
- Tests : +9 dans `tests/test_audit_p0_fixes.py` (SSRF, 401s, validate_upload,
  doublons de routes) ; tests OG sur hôtes résolubles (la garde fait du DNS)
- suite **1025/1025** · `ruff check app tests` OK
2026-09-30 23:12:20 -04:00
bruno 8ab6569974 fix: A11 + A18 — path traversal avatar et XSS/flags sur la vue publique (v7.3.2)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 22m0s
- A11 : `GET /api/settings/avatar/{filename:path}` → `resolve()` + `relative_to()` (motif de `serve_uploaded_file`), 403 hors de `/data/avatars`
- A18 : `GET /workspace/public/{id}` → 404 HTML explicite pour `permission_type` restricted/private, `html.escape` sur le nom, l'icône et les titres de lignes (le f-string HTML ne passe pas par Jinja2)
- `tests/test_audit_p0_fixes.py` : 3 tests de non-régression (traversal, échappement, hidden restricted)
- ROADMAP A11/A18 cochés · CHANGELOG/WORKLOAD/VERSION → 7.3.2 · suite **1019/1019** · `ruff check app tests` OK
2026-09-30 22:40:34 -04:00
bruno 69a0aceba6 fix: A10 — autoescape Jinja2 activé partout via un Environment partagé
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m54s
- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
2026-09-30 22:28:48 -04:00
bruno d76d7943fc docs(roadmap): A3-A8 cochés — bloc fallback admin corrigé, suite 1016/1016
FlowDeck CI / lint (push) Successful in 2m0s
FlowDeck CI / test (push) Successful in 22m31s
FlowDeck CI / docker (push) Successful in 1m45s
Commit d125eb3 (code + tests).
2026-09-30 22:05:06 -04:00
bruno d125eb399e fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00
bruno e6c1f7dbb3 docs(roadmap): A1/A2/A9 cochés — deps, cycle commit+tag v7.3.0, désindexation .db + rotation secret
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m17s
FlowDeck CI / docker (push) Successful in 2m13s
Suite 1016/1016 verts.
2026-09-30 20:20:19 -04:00
bruno 465853ac59 fix(tests): A1 — fin du rebinding app.config.settings dans test_v54 (isolation rétablie, 1016/1016 verts)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / test (push) Failing after 3h8m45s
FlowDeck CI / docker (push) Skipped
Le rebind (`app.config.settings = Settings()`) laissait tous les modules déjà
importés (sso_provisioning, trash, …) sur un objet périmé : le test
test_v67_sso::test_env_config_fallback_when_table_empty échouait dès qu'il
tournait après test_v54 dans le même worker (-n auto). Mutation sur place
comme le préconise conftest.py.
2026-09-30 20:19:09 -04:00
bruno 1706ad1ee9 feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
2026-09-30 20:02:57 -04:00
bruno d074689b18 feat: v6.7.0 — SSO/SAML + OIDC entreprise (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, SSO only, onglet Settings « SSO / Enterprise », API /api/v2/sso/*, help, migration 23, docs + OpenAPI 439 chemins) · 802 tests verts
FlowDeck CI / lint (push) Successful in 1m28s
FlowDeck CI / test (push) Successful in 11m52s
FlowDeck CI / docker (push) Successful in 1m51s
2026-09-24 13:32:17 -04:00
333 changed files with 19515 additions and 354260 deletions
+7
View File
@@ -14,3 +14,10 @@ build/
node_modules/
Dockerfile
.dockerignore
# A9 — jamais de DB ni de fichiers de test dans l'image
*.db
*.db-*
test-commit.md
upload_test.txt
e2e/
+26 -2
View File
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
DEFAULT_LANG=fr
# ── Database ──
# SQLite (default): sqlite:////data/flowdeck.db
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
# schéma retombe silencieusement sur /data/flowdeck.db).
DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ──
@@ -58,3 +58,27 @@ SMTP_PASSWORD=
SMTP_FROM=FlowDeck <[email protected]>
SMTP_USE_TLS=true
APP_BASE_URL=http://localhost:8080
# ── SSO / Enterprise (v6.7.0) ──
# Fallback de démarrage uniquement : dès qu'un admin enregistre une configuration
# dans Settings → Admin → SSO / Enterprise, la table `sso_config` prime sur le .env.
# Le bouton SSO n'apparaît sur la page de connexion que si une config est active.
# SSO_PROVIDER=saml # saml | oidc (vide = SSO désactivé)
# SSO_NAME=Company SSO # libellé du bouton
# SSO_ONLY=false # true = refuser le login local (les admins gardent le leur)
# SSO_AUTO_PROVISION=true # créer le compte au premier login SSO
# SAML :
# SSO_ENTITY_ID=https://idp.example.com/saml/metadata
# SSO_SSO_URL=https://idp.example.com/saml/sso
# SSO_SLO_URL=https://idp.example.com/saml/slo
# SSO_X509_CERTIFICATE=-----BEGIN CERTIFICATE-----
# SSO_SIGN_REQUESTS=false # signer les AuthnRequests / LogoutRequest
# OIDC :
# SSO_ISSUER_URL=https://auth.example.com/realms/flowdeck
# SSO_CLIENT_ID=
# SSO_CLIENT_SECRET=
# SSO_SCOPE=openid profile email
# Mapping (JSON) :
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
# SSO_DEFAULT_WORKSPACE_ID=0
+9
View File
@@ -17,3 +17,12 @@ dist/
.ua/.trash-*/
.ua/.understandignore
uv.lock
# A9 — jamais de DB ni de fichiers de test dans git
*.db
*.db-*
test-commit.md
upload_test.txt
e2e/node_modules/
e2e/shots/
e2e/test-results/
+1
View File
@@ -1710,5 +1710,6 @@ docker compose restart flowdeck
- **Kanban flexible** — Colonnes custom, WIP limits
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py`
- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py`
- **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling
+534
View File
@@ -1,5 +1,539 @@
# Changelog - FlowDeck
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
la valeur par défaut (il signe les sessions)
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
(croissance mémoire bornée)
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
scheduler passés en `warning`
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
dupliqué retiré du ROADMAP
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
(format ISO naïf identique, zéro changement de comportement)
### Notes
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
l'alignement à 3.13 implique un rebuild d'image à valider
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
d'événements/webhooks et des écritures sont enfin visibles dans les logs
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
transaction au lieu de commiter une collection sans schéma
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
point d'entrée des connexions) ; le wrapper async + les 510 call sites
synchrones sur l'event loop restent à migrer
### Tests
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
### Fixed
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
`ping()` du serveur vers un `api_base` interne
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
### Fixed
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
préfixes sortent d'`EXCLUDED_PATHS`
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
(interpolations Jinja neutralisées) — 0 échec avant/après
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
### Fixed
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
`welcome.html` équipés du header
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
route, pas le 403 du middleware) → suite **1026/1026**
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
### Fixed
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
couverte par A7
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
### Fixed
- **A12** — unfurl OG : `follow_redirects` manuel + `_is_public_host` à chaque
saut → 400 vers loopback/link-local (ex. `169.254.169.254`)
- **A13** — automations : `Depends(_require_session)` sur le router entier
(CRUD, run, press-button) + action `webhook` validée avant POST
- **A15** — webhooks sortants : admin exigé + URL publique (SSRF scheduler)
- **A17** — router legacy `/api` : session ou Bearer (`/api/v1`) ; allowlist
explicite `/api/health`, `/api/frontend-error`
- **A22** — uploads locaux : session exigée, `validate_upload` branché (10 MB +
extensions), `FLOWDECK_DATA_DIR` remplace le `/data` codé en dur
- **A23** — N+1 : `GROUP BY` (compteurs de pages), `executemany` (cards de sync
+ duplicata de propriétés avec remap d'ids vérifié)
- **A24** — 2 routes silencieusement écrasées supprimées + test « aucun doublon
méthode+chemin » sur les 680 routes
### Tests
- +9 non-régressions dans `tests/test_audit_p0_fixes.py` → suite **1025/1025**
## v7.3.2 (2026-09-30) — Audit sécurité : A11 + A18
### Fixed
- **A11** — `GET /api/settings/avatar/{filename:path}` : `resolve()` +
`relative_to()` (le motif de `serve_uploaded_file`) → 403 hors `/data/avatars`
- **A18** — `GET /workspace/public/{id}` : 404 explicite pour les bases
`restricted`/`private` (`permission_type`) et `html.escape` sur nom, icône et
titres de lignes — ce f-string HTML ne passe pas par Jinja2, donc l'autoescape
A10 ne le couvrait pas
- Tests : `tests/test_audit_p0_fixes.py` (3 non-régressions) — suite **1019/1019**
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
### Fixed
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
cycle v6.8→v7.3 committé + tag `v7.3.0`
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
sorti de la liste CSRF exemptée
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
chemin legacy `user_id=0` supprimé
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
global), placeholder user créé en `is_admin=0`
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
legacy single-user sur les collections)
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
rotation de `APP_SECRET_KEY`
- **A10** — `app/templating.py` : un seul `ENV` avec
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
en `|tojson`)
### Tests
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
> expiration, guests sans compte, réactions, follows, analytics de page et
> trois nouveaux blocs rendus côté serveur. Design : `docs/V73_Wiki_Teamspaces_Polish.md`.
### Added
- **Teamspaces** — `app/services/wiki.py` + `app/routers/wiki.py` : `teamspaces`
(`workspace_id`, `private`, `UNIQUE(workspace_id, name)`) et
`teamspace_members` (rôles `owner`/`editor`/`commenter`/`viewer`) ; CRUD +
listing par workspace, ajout/retour de membres ; `private=1` → **404** (pas
403) pour les non-membres, comme les collections restricted ; un teamspace
public reste cantonné au workspace (pas de `viewer` implicite pour un compte
qui n'en est pas membre) ; `pages.teamspace_id` + `collections.teamspace_id`
- **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`,
`expires_at` 90 j par défaut, re-vérifier remplace) ; l'index
`GET /api/v2/wiki/verified` exclut les badges expirés et les pages de
teamspaces privés ; sweep `POST /api/v2/wiki/verify-expiry-sweep` (admin)
notifie le vérificateur à J-7 (`page.verification_expiring`) ; la
vérification exige un rôle editor/owner/admin, sinon 403
- **Guests sans compte** — `guest_shares` (`token`, `role viewer|commenter`,
`expires_at`, `revoked`) ; accès par `GET /g/{token}` **sans session**,
enregistrement d'une vue, révocation idempotente ; page 404 HTML dédiée au
lieu d'une redirection vers `/workspaces`
- **Collab polish** — `comment_reactions` (agrégation par emoji + users,
toggle), `page_follows` (toggle + followers), `page_views` (compteurs
journaliers, séries sans trou via `view_stats`)
- **Wiki Home** — `GET /api/v2/wiki/home` (teamspaces + verified + recents)
- **Blocs** — `app/services/wiki_blocks.py` : `mermaid` (SVG inline si
`mmdc` est installé, sinon `<pre class="mermaid">` rendu côté client),
`equation_inline` (KaTeX, source sanitizée : `<`, `>`, `\` retirés pour
empêcher la fermeture anticipée du délimiteur ou l'injection de balises),
`progress` (agrégation directe sur `property_values_json` → barre %) ; les
trois sont rendues par `export.blocks_to_html` (donc présentes dans
l'export HTML/PDF) et exposées via `POST /api/v2/wiki/blocks/preview`
- **Migration 29** — `teamspaces`, `teamspace_members`, `page_verifications`,
`comment_reactions`, `page_follows`, `guest_shares`, `page_views` +
colonnes `teamspace_id` sur `pages`/`collections`
- **Sidebar teamspaces** — section `Teamspaces` dans `base.html` (état Alpine
`teamspaces`/`loadTeamspaces`/`openTeamspace`, section ouverte par défaut,
icône/label/ordre, fallback du panneau de personnalisation) →
`GET /api/v2/wiki/teamspaces` accepte désormais l'omission de `workspace_id`
(listing cross-workspace avec `workspace_name`), page HTML
`GET /wiki/teamspaces/{id}` (pages + collections du teamspace, rôle affiché) ;
entrée `teamspaces` dans `sidebar_config.DEFAULT_CONFIG`
- **Notif `page.updated` aux followers** — `wiki.notify_followers_of_page_update`
(une par 10 min, `actor_id` exclu) branchée dans `automations.fire_event` ;
payloads `actor_id` ajoutés dans `board.update_page` et `board.save_page_blocks` ;
commenter une page = suivre (auto-follow `wiki.ensure_follow`, défaut ON)
- **Charts avancés** — type `number` (KPI) avec agrégats `count|sum|avg|min|max`
dans `collections._render_chart` (+ `_chart_aggregate`/`_fmt_number`) ; le « 0 »
n'est plus forcé à 1 ; les dashboards multi-DB se rendent via
`GET /db/{collection_id}/dashboards/{dashboard_id}` (widgets `collection_id`,
≤ 40 widgets, ≤ 200 lignes/chart) ; `view_collection` choisit maintenant la
config de vue correspondant au `view_type` demandé
- **Unfurl `gitea:`/`github:`** — `POST /board/api/og/metadata` résout les refs
`gitea:owner/repo` / `github:owner/repo` via `GiteaClient.get_repo_info`
(ajouté) ou `GitHubAdapter` (token optionnel, sinon API publique) sans
télécharger la page ; champs bookmark `url/title/description/image/site_name`
conservés dans l'autosave du bloc
- **UI Settings → Audit** — `settings.html` : l'onglet `admin-audit` interroge
désormais `/api/v2/audit/logs` (sources `api`/`permissions`/`sso`, filtres
`actor`/`action`, pagination « Load more », export CSV)
- **SSO 21 casses** — dépendances `python3-saml==1.16.0` + `authlib==1.8.0`
(plus `xmlsec`/`isodate`/`joserfc`) installées → `test_v67_sso.py` **38/38**
- **Sécurité** — `sanitize_equation` retire désormais `<`/`>`/`\` (pas de
breakout KaTeX/markup), `revoke_guest` 404 fondé sur l'existence, pas le
rowcount
### Tests
- `tests/test_v73_wiki_polish.py` : **58 → 72 tests** (sidebar cross-workspace +
page teamspace owner/outsider, auto-follow par commentaire, notif
`page.updated` throttlée + acteur exclu, `ensure_follow` idempotent, regex +
unfurl gitea/github + endpoint `/board/api/og/metadata`, KPI + `_chart_values`
(0 conservé) + dashboards multi-DB + 404)
- `ruff check app tests` OK · suite complète `python -m pytest -n auto` :
**1016 passed** (était : 981 passed / 21 failed en SSO avant install des deps)
---
## v7.2.0 (2026-09-29) — Enterprise admin : SCIM 2.0, 2FA, Audit, gouvernance agents
> Le socle administration d'une instance auto-hébergée en équipe : provisionnement
> SCIM depuis l'IdP, TOTP + passkeys, journal d'audit unifié et garde-fous
> d'exécution pour les agents. Design : `docs/V72_Enterprise_SCIM_2FA.md`.
### Added
- **SCIM 2.0** — `app/routers/scim.py` : `GET/POST /scim/v2/Users`,
`GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, schémas
core:2.0:User, `active` → `users.is_active` + révocation `user_sessions`,
`Operations` PATCH `active`/`userName`) ; tokens SHA-256 stockés, affichés
une seule fois, révocables (`/api/v2/scim/tokens`) ; exempté CSRF (clients
IdP sans cookie) et 404 `application/scim+json` au lieu d'une redirection
- **TOTP 2FA** — `app/services/two_factor.py` : secret chiffré Fernet au repos,
10 codes de secours à usage unique (SHA-256), Défi `pending` signé 5 min ;
`POST /auth/local-login` renvoie `2fa_required` sans créer de session, puis
`POST /auth/local-verify` l'échange contre une session ; setup/activate/
disable/status côté utilisateur
- **Passkeys WebAuthn** — `app/routers/webauthn.py` : enregistrement
(`register/begin|finish`, attestation vérifiée, COSE stocké) et connexion
**sans mot de passe** (`login/begin|finish`, anti-rejeu `sign_count`,
vérif. origine/RP) ; listing + suppression des clés
- **Domain claims** — `POST /api/v2/domain-claims` (normalisation lowercase,
jeton `.well-known/flowdeck-verify.txt`), `POST .../verify` (fetch HTTPS du
domaine + comparaison), `enforce_sso` qui bloque le login local par domaine
dans `auth.local_login` (les admins gardent l'accès local) ; jeton jamais
renvoyé par le listing
- **Audit unifié** — `app/routers/audit.py` : `GET /api/v2/audit/logs` fusionne
`api_audit_log` + `permission_audit_log` + `sso_login_history` en un schéma
commun, filtres `source`/`actor`/`action`, pagination, export
`?format=csv` (admin ou Bearer `read:admin`)
- **Gouvernance des agents** — `app/services/agent_policies.py` + `app/routers/governance.py` :
`agent_policies` (liste d'outils autorisés par workspace, `max_steps`,
`require_approval`) consultée par `AgentEngine` **avant** les ACL, file
`agent_approvals` pour les écritures, décision admin
(`/api/v2/agent-approvals/{id}/decide`) + événement
`agent.run.approval_requested`
- **Migration 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`,
`agent_policies`, `agent_approvals` + `users.totp_secret_enc`,
`users.totp_backup_hashes`
### Fixed
- Les 404 sur `/scim/v2` et `/auth/webauthn` renvoyaient une redirection 302
vers `/workspaces` (handler global) : ils retournent désormais du JSON, et
`/scim/v2` répond en `application/scim+json`
- `/scim/v2` et les routes 2FA/WebAuthn ajoutées à la liste d'exclusion CSRF
(authentification Bearer, pas de cookie de session)
### Tests
- `tests/test_v72_enterprise.py` : **52 tests** (migration 28, SCIM tokens/CRUD/
suspend/duplicate/404, 2FA setup-activate-verify-backup-chiffrement-désactivation,
challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV +
pagination, politiques et gate d'approbation)
- `ruff check app tests` OK · `tests/test_agent.py` + `tests/test_app.py` :
**261 verts** (la gouvernance ne casse pas l'engine)
---
## v7.1.0 (2026-09-28) — Calendar sync + Meeting Notes
> Calendrier bidirectionnel Google/CalDAV + transcription → résumé IA qui
> déclenche les agents (pattern Notion 07/2026). Design : `docs/V71_Calendar_Meetings.md`.
### Added
- **Sync bidirectionnelle** — `app/services/calendar_sync.py` : `calendar_links`
(tokens Fernet, `collection_id`, `date_property`), pull (event → ligne datée +
`external_event_id`) + push, boucle 15 min dans le lifespan ; conflits
(édité des 2 côtés → last-write-wins + notif `calendar.conflict`) ; lignes
touchées par le pull jamais repoussées ; API CRUD + `POST .../sync`
(`app/routers/meetings.py`, session ou Bearer `write`, tokens jamais leakés)
- **Sans dépendance** — Google Calendar REST (401 → « relink »), CalDAV brut
(REPORT + parseur multistatus, PUT) ; I/O module-level monkeypatchables
- **Meeting Notes v2** — `app/services/meetings.py` : upload audio 100 MB
(mp3/wav/m4a/ogg/flac/aac), transcription `STT_COMMAND` ou transcript manuel,
résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized`
(branché automations v7.0) ; endpoints upload/texte/summarize
- **Free/busy** — `GET /db/{id}/calendar/freebusy` (busy + free weekdays,
récurrences expandues, 1..370 j)
- **Migration 27** — `calendar_links`, `meeting_transcripts`,
`collection_pages.external_event_id` + index
### Tests
- `tests/test_v71_calendar_meetings.py` : **15 tests** (migration, links,
pull/push/idempotence/conflit + notif, 502, CalDAV, freebusy, meetings ×5)
- `ruff check app tests` OK · suite **871 verts** (21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement)
---
## v7.0.0 (2026-09-28) — Automations v2 + Workers lite
> Du if-this-then-that aux chaînes multi-triggers + custom code sandboxé,
> parité Notion Automations + Workers. Design : `docs/V70_Automations_Workers.md`.
### Added
- **Automations multi-étapes** — `automation_steps` (trigger/condition/delay/action
ordonnés) : CRUD `GET/POST /workspace/automations/{id}/steps`,
`PUT/DELETE /workspace/automations/steps/{id}` (session, validation serveur),
`PUT .../mode` (`any` défaut / `all` fenêtre 5 min) ; conditions AND réutilisant
`match_condition_props` ; `form.submitted` déclenchable ; legacy sans steps intact
(matcher legacy ignore les automatisations à steps → pas de double run)
- **Nouvelles actions** — `slack` (incoming webhook, URL chiffrée Fernet au repos,
décryptée à l'exécution), `email` (`user:<id>` résolu ou reply-to créateur,
repli propre sans SMTP), `forge_issue` (Gitea via `GiteaClient` / GitHub API,
token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`),
`delay` (0..86400s validé, sleep plafonné 300s) ; interpolation `[[prop]]` /
`{{title}}` conservée ; backends module-level = monkeypatchables
- **Bouton DB natif** — type `button` (`PROPERTY_TYPES`), `button_automation_id`,
`POST /api/automations/press-button` (CSRF-exempt, 400 explicites)
- **Workers lite** — `app/services/workers.py` (lint AST : imports réseau/OS,
`open/exec/eval`, dunders ; builtins restreints ; thread + timeout 30s ;
budget journalier ; fork des partagés) + `app/routers/workers.py`
(CRUD `/api/v2/workers*` session/Bearer `write`, code masqué aux non-owners,
run/runs/fork/usage) ; crons branchés sur la boucle scheduler 60s
- **Migration 26** — `automation_steps`, `workers`, `worker_runs`,
`automations.trigger_mode`, `collection_properties.button_automation_id`
### Tests
- `tests/test_v70_automations_workers.py` : **31 tests** (migration, steps,
any/all, chaînes, delay, 4 nouvelles actions, secret chiffré, button,
legacy, workers ×11)
- `ruff check app tests` OK · suite **855 verts** (21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement ; 1 flaky parallèle
`test_env_config_fallback…` qui passe isolé)
---
## v6.9.0 (2026-09-28) — Recherche sémantique + Ask AI
> Retrouver (hybride lexical + vectoriel) et demander (RAG avec citations),
> parité Notion Enterprise Search + AI Q&A. Design : `docs/V69_Search_Ask_AI.md`.
### Added
- **Moteur sémantique** — `app/services/semantic_search.py` : chunking chevauchant
(1200 cars / overlap 150, récursif dans `children`), encodeur hashed-TF `hash-256`
(md5 % 256, L2, déterministe, zéro dépendance, `embed_texts()` pluggable),
cosinus pur Python, fusion RRF (k=60), job incrémental `index_pending()` (batch 50,
scheduler 5 min dans le lifespan) + `purge_orphans()`
- **Recherche hybride** — `GET /api/v2/search/hybrid` (`app/routers/search_ai.py`,
session ou Bearer `read`) : lexical FTS5/LIKE + vecteurs, filtre `workspace_id`,
scope membership, `PermissionManager` (pages `restricted` masquées),
`search_excluded` respecté, pagination + `X-Total-Count`
- **Ask AI** — `POST /api/v2/search/ask` : top-8 chunks autorisés (ACL **avant** prompt),
`LLMClient.complete()` si provider configuré sinon extractif offline avec
`[[fdpage:ID]]`, citations résolues (titres, « Deleted page » gérée), cache 10 min,
rate-limit 30/min, `api_audit_log`
- **Observabilité** — `GET /api/v2/search/index-status` (ressources, vecteurs, modèle)
- **Migration 25** — `semantic_embeddings`, `semantic_index_state`,
`pages.search_excluded`
### Tests
- `tests/test_v69_search_ask.py` : **24 tests** (migration, chunk/overlap, déterminisme,
cosinus, index idempotent, exclusion, purge, rappel partiel, hybride ×7, ask ×6,
index-status)
- `ruff check app tests` OK · suite **825 verts** (seuls 21 échecs `test_v67_sso.py`
pré-existants — `onelogin` absent de l'environnement)
---
## v6.8.0 (2026-09-28) — Sites & Forms publics
> Parité Notion Sites + Forms : publier un mini-site multi-pages et collecter
> des réponses anonymes dans une database. Design : `docs/V68_Sites_Forms.md`.
### Added
- **Sites multi-pages** — `app/routers/sites.py` : CRUD `GET/POST/PATCH/DELETE /api/v2/sites`
(session ou Bearer + scope `write`, pagination `X-Total-Count`, `api_audit_log`),
`GET/POST/DELETE /api/v2/sites/{id}/pages` (arbre ordonné, racine protégée),
`GET /api/v2/sites/{id}/stats` (vues jour + total) ; rendu public `GET /s/<slug>` +
`GET /s/<slug>/<page-slug>` (nav latérale, thèmes light/dark, blocs/synced/wiki résolus)
- **Gating & SEO** — mot de passe (`password_utils` salé, cookie signé 24h,
`GET|POST /s/<slug>/auth`), expiry (410), `noindex`, OG/Twitter cards,
`GET /s/<slug>/sitemap.xml`, domaine custom via header `Host`
- **Forms publics** — `PUT/GET /api/v2/collections/{id}/form` (`enabled`, `public_token f_*`,
`fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>`
(formulaire no-auth, `?embed=1` sans chrome) + `POST /f/<token>` (anonyme :
rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ;
chaque soumission = `collection_pages` + `form_responses` (ip_hash jour, pas d'IP),
notif in-app + trigger `form.submitted`
- **Migration 24** — tables `sites`, `site_pages`, `site_views`, `form_responses`,
colonne `collections.form_config_json`
- CSRF exempte `/s/` + `/f/` (soumissions anonymes cross-site)
### Tests
- `tests/test_v68_sites_forms.py` : **20 tests** — migration 24, CRUD, slug/conflit,
auth 401, isolation inter-users, pages add/remove, rendu home/subpage/blocs, 404,
vues comptées, password gate, expiry 410, sitemap, noindex, form config/submit JSON,
required 400, 404, honeypot, rate-limit 429, embed
- `ruff check app tests` OK · pas de régression (801 verts ; seuls 21 échecs
`test_v67_sso.py` pré-existants — dépendance `onelogin` absente de l'environnement)
---
## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
> d'entreprise (SAML 2.0 ou OpenID Connect + PKCE), auto-provisioning des
> comptes, mapping des groupes de l'IdP vers les rôles workspace, mode
> « SSO only » et configuration admin dans Settings. Design :
> `docs/V6_SSO_SAML_Enterprise_Auth.md` (checklist §9 cochée).
### Added
- **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) :
login SP-initié `GET /auth/saml/login` (RelayState = id AuthnRequest + jeton CSRF
en DB), ACS `POST /auth/saml/callback` (signature, audience, destination,
InResponseTo, anti-replay `sso_requests`), SP metadata `GET /auth/saml/metadata`,
SLO `GET|POST /auth/saml/logout` (relais LogoutRequest/Response vers l'IdP) ;
`allowSingleLabelDomains` activé pour les hôts homelab/LAN
- **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) :
`GET /auth/oidc/login` (`state` + `code_verifier` stockés en DB),
`GET|POST /auth/oidc/callback` (code → token → userinfo ; ID token vérifié via
JWKS : aud/iss/nonce/exp), discovery + JWKS mis en cache 1 h,
`GET|POST /auth/oidc/logout` (`end_session_endpoint` si présent)
- **Provisioning** — `app/services/sso_provisioning.py` : création automatique de
l'utilisateur au 1er login (`auth_method=saml|oidc`, lien via email), rôle par
défaut + `default_workspace_id`, mapping groupes IdP → rôles workspace
(re-synchronisé à chaque login + `POST /api/v2/sso/sync`), mode **SSO only**
(login local refusé sauf administrateurs — design §7.1), secrets chiffrés
Fernet dans `sso_config` (champ secret vide = conserver l'existant), détection
de rejeu via `sso_requests` (TTL 15 min), `sso_login_history` (succès + échecs),
`safe_next_path` (pas de redirection externe)
- **Routeur** — `app/routers/sso.py` : parcours navigateur + API
`GET|POST|PUT|DELETE /api/v2/sso/config` (admin, header `X-CSRF-Token`,
`audit_log`), `GET /api/v2/sso/providers` (public, boutons de la page de login),
`GET /api/v2/sso/workspaces`, `POST /api/v2/sso/sync`, `GET /api/v2/sso/history` ;
CSRF exclut les préfixes `/auth/saml` et `/auth/oidc` (POST IdP cross-site)
- **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
- **UI admin** — onglet « SSO / Enterprise » dans Settings : statut, config
SAML (Entity ID, SSO/SLO URL, certificat PEM, mapping attributs JSON, signature
des AuthnRequests) et OIDC (issuer, client id/secret, scope), provisioning
(auto-provision, SSO only, espace par défaut), tableau groupes → rôles,
URL des métadonnées SP copiable, bouton « Re-sync des groupes », désactivation ;
bouton SSO sur la page de login (nom dynamique, masqué si non configuré)
- **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`,
`get_sso_roles()`, `sync_sso_permissions()` (les grants SSO sont des lignes
`workspace_members` explicites ; le fallback « viewer » implicite n'en est pas un)
- **Help** — section « Enterprise SSO » + badge `.help-badge.sso` dans `/help`
- **`.env` (fallback bootstrap)** — `SSO_PROVIDER`, `SSO_NAME`, `SSO_ONLY`,
`SSO_AUTO_PROVISION`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`,
`SSO_X509_CERTIFICATE`, `SSO_SIGN_REQUESTS`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID`,
`SSO_CLIENT_SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`,
`SSO_DEFAULT_WORKSPACE_ID` (la config admin prime sur l'environnement ;
section ajoutée dans `.env.example`)
- **Dépendances** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0`
### Changed
- `/logout` délègue la déconnexion à l'IdP (SLO) quand la session vient de SAML,
puis détruit la session locale ; sinon comportement existant
- OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
(operation IDs uniques — les routes multi-méthodes sont enregistrées en deux
routes mono-méthode)
### Tests
- `tests/test_v67_sso.py` : **38 tests** — API de configuration admin
(validation, CSRF, secret jamais renvoyé, secret vide préservé, réinitialisation,
DELETE), métadonnées SP, login SAML complet contre un IdP mock signé (y compris
SLO), OIDC complet (discovery/token/userinfo/JWKS mockés), cas négatifs
(mauvais audience/issuer/destination/nonce/state, rejeu, signature altérée,
assertion expirée), auto-provisioning, group mapping (nom → rôle workspace),
sso_only, historique, rate limit, endpoint public `providers`, extension
`PermissionManager`
- `pytest -n auto` → **802 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
---
## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace
> Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
> **v6.6.0** — Agent phase 5 : API publique agent (`/api/v2/agents`) + marketplace de skills · avant : v6.5.x synced blocks, v6.4.0 realtime, PWA offline
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
## Quick Start
+236 -17
View File
@@ -849,6 +849,40 @@ Détails livrés :
---
## v6.7.0 — SSO / SAML + OIDC entreprise (Enterprise Auth) ✅ (2026-09-24)
> **Objectif** : dernière feature de la roadmap v6.0.0 — authentification fédérée
> via un IdP d'entreprise. **COMPLETED**. Design : [`docs/V6_SSO_SAML_Enterprise_Auth.md`](docs/V6_SSO_SAML_Enterprise_Auth.md).
- [x] **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : login SP-initié
(`/auth/saml/login`), ACS signé (signature/audience/destination/InResponseTo validés),
SP metadata public (`/auth/saml/metadata`), SLO (`/auth/saml/logout`) ; `allowSingleLabelDomains`
pour les hôts homelab/LAN
- [x] **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : `state` +
`code_verifier` en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h
- [x] **Provisioning** — `app/services/sso_provisioning.py` : auto-provision au 1er login
(`auth_method=saml|oidc`, lien par email), rôle par défaut + `default_workspace_id`,
mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode **SSO only**
(login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay `sso_requests`,
historique `sso_login_history`
- [x] **Routeur** — `app/routers/sso.py` : parcours navigateur + API admin
`GET/POST/DELETE /api/v2/sso/config` (admin + CSRF), `GET /api/v2/sso/workspaces`,
`POST /api/v2/sso/sync` ; CSRF exclut `/auth/saml/*` + `/auth/oidc/*`
- [x] **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
- [x] **UI admin** — onglet « SSO / Enterprise » dans Settings (config SAML/OIDC, provisioning,
groupes → rôles, URL métadonnées SP copiable, désactivation) ; bouton SSO sur la page de login
- [x] **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, `get_sso_roles()`,
`sync_sso_permissions()`
- [x] **Help** — section « SSO (Enterprise) » + badge `.help-badge.sso`
- [x] **Docs** — CHANGELOG, WORKLOAD, README, ARCHITECTURE, `docs/API_GUIDE_V6.md` §2.5,
OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
- [x] **Tests** — `tests/test_v67_sso.py` : **38 tests** (config admin + CSRF + secret préservé,
métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper,
group mapping, `sso_only`, SLO, historique, rate limit, extension `PermissionManager`)
- [x] **Version** — 6.7.0 · `ruff check app tests` OK · **suite 802 verts, 0 skip**
---
## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24)
> **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » :
> exposer l'agent en API publique pour les intégrations tierces et rendre les skills
@@ -908,18 +942,113 @@ Détails livrés :
---
## v6.0.0 — Pro (futur)
## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [x] **SSO/SAML** — enterprise authentication ✅ (livré v6.7.0) — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
---
## v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)
> **Contexte (2026-09-28)** : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité
> (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO).
> Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : **Sites, Forms,
> Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP**.
> Référence : `notion.com/product/features` + `notion.com/releases` (Workers, Meeting Notes → agents, Developer bar, MCP).
### v6.8.0 — Sites & Forms publics ✅ (2026-09-28)
> **Objectif** : publier (site multi-pages) et collecter (formulaires anonymes).
> Parité Notion Sites + Forms. **Doc** : [`docs/V68_Sites_Forms.md`](docs/V68_Sites_Forms.md)
- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s/<slug>/sitemap.xml`, meta robots, preview sociale
- [x] **Gating public** — mot de passe (hash salé `password_utils`, cookie signé 24h), expiry (410), `noindex`, analytics vues (`site_views` : jour + compteur, pas d'IP brute)
- [x] **Routes** — `GET /s/<slug>` (home), `GET /s/<slug>/<page-slug>` (résolution slug + id), `GET|POST /s/<slug>/auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>` (no-auth) + `POST /f/<token>` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
- [x] **Tests** — `tests/test_v68_sites_forms.py` (**20 tests** : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
- [x] **Embeddings** — tables `semantic_embeddings` (`resource_type`, `resource_id`, `chunk_id`, `chunk_text`, `embedding BLOB`, `model hash-256`) + `search_index_state` ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, `embed_texts()` pluggable) ; job incrémental (`updated_at > indexed_at`, batch 50, scheduler 5 min dans le lifespan) + `purge_orphans()`
- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
- [x] **Ask AI** — `POST /api/v2/search/ask` (`{question, workspace_id}` → `{answer_markdown, citations}`) : top-8 chunks autorisés (ACL avant prompt), `LLMClient.complete()` si provider configuré sinon extractif offline avec `[[fdpage:ID]]`, cache 10 min, rate-limit 30/min, audit
- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
> Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe).
> **Doc** : [`docs/V70_Automations_Workers.md`](docs/V70_Automations_Workers.md)
- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
- [x] **Compat legacy** — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
> **Doc** : [`docs/V71_Calendar_Meetings.md`](docs/V71_Calendar_Meetings.md)
- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
- [x] **Providers sans dépendance** — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
- [x] **Tests** — `tests/test_v71_calendar_meetings.py` (**15 tests** : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + `meeting.summarized` → automation, validations audio, 404)
- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https://<domain>/.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
- [x] **Tests** — `tests/test_v72_enterprise.py` (**52 tests** : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
- [x] **Teamspaces** — `teamspaces` (`workspace_id`, `name`, `private`) + `teamspace_members` (rôles owner/editor/commenter/viewer) ; `pages.teamspace_id` + `collections.teamspace_id` ; `private` → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `<pre class="mermaid">` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
- [x] **Collab polish** — `comment_reactions` (agrégation emoji + toggle), `page_follows` (toggle + followers), `guest_shares` → `GET /g/{token>` sans compte (vue enregistrée, 404 HTML si expiré/révoqué), `page_views` (compteurs journaliers, séries sans trou)
- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note |
@@ -929,21 +1058,33 @@ Détails livrés :
---
## 🎯 Ordre de priorité recommandé (état 2026-09)
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
### Cycle v6 — livré, pour mémoire
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ livré
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ livré
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ livré
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ livré
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ livré
13. ~~**v5.14.0 → Synced blocks**~~ ✅ livré · ~~**v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO**~~ ✅ livré
### Cycle v7 — à livrer
| Ordre | Version | Effort | Impact |
|---|---|---|---|
| 1 | **v6.8.0 → Sites & Forms publics** | M | 🔴 publier + collecter |
| 2 | **v6.9.0 → Search sémantique + Ask AI** | M | 🔴 retrouver |
| 3 | **v7.0.0 → Automations v2 + Workers** | L | 🔴 automatiser |
| 4 | **v7.1.0 → Calendar sync + Meeting Notes** | M | 🟠 |
| 5 | **v7.2.0 → SCIM + 2FA + Audit UI** | M | 🟠 enterprise |
| 6 | **v7.3.0 → Wiki/Teamspaces + Polish** | S–M | 🟢 finition |
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
---
## Résumé des phases
@@ -962,4 +1103,82 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
*Dernière mise à jour: 2026-09-24 — **v6.6.0 Agent phase 5 : API publique agent & skill marketplace COMPLETED** (`/api/v2/agents/*` en Bearer+scopes avec run synchrone JSON + audit/undo, export/import de skills + galerie de 6 presets, entrée « Galerie » branchée dans la palette `/` du panneau, webhooks `agent.run.started`/`failed` enfin émis, OpenAPI régénéré 427 chemins ; 15 tests dédiés) + **v6.5.1** webhooks_v2 (749 verts) + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **suite 764 verts, 0 skip**. Les 5 phases du plan agent sont ✅. Reste: **SSO/SAML**.*
*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
---
## 🔴 Anomalies & dette technique — audit complet 2026-09-30
> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
### 🔴 P0 — Critique (avant toute exposition réseau)
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
### 🟠 P1 — Hautes
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
- [x] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
- [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
- [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] **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**.*
- [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
### 🟡 P2 — Moyennes
- [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**.*
- [ ] **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**.*
### 🟢 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**.*
### ✅ Vérifié non-problème (ne pas re-checker)
`sort`/`direction` de l'`ORDER BY` f-string **whitelisté** (`api_v2.py:696-703`) · `permissions._grant_common(table, …)` ne reçoit que des littéraux de ses 3 appelants · `admin.py` utilise bien `Depends(admin_required)` · `/api/v2/*` = `get_bearer_user` + scopes · SCIM Bearer-only (commenté `csrf.py:19-20`) · `serve_uploaded_file` et `_file_page_disk_path` ont la bonne garde de traversal · `url_fetch._is_public_host` est un vrai garde SSRF (à réutiliser) · aucun `password_hash`/valeur de token dans les réponses API (les tests le couvrent) · `.env` bien gitignoré, aucun secret en log · les 10 schedulers ont un `try` interne · 0 test sans `assert`, 0 test sur le réseau réel.
### ⚡ Correctifs immédiats (avant le prochain cycle — ~30 min au total)
1. **A2** : commit + push + tag du cycle v7 (sinon tout le reste risque de partir avec une réinit).
2. **A1** : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` → les 13 échecs deps disparaissent, le 14ᵉ reste à isoler.
3. **A9** : `git rm --cached flowdeck.db flowdeck_dev.db test-commit.md upload_test.txt` + `.gitignore`/`.dockerignore` + rotation de `app_secret_key`.
→ 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.**
+1 -1
View File
@@ -1 +1 @@
6.6.0
7.3.9
+12 -2
View File
@@ -1,7 +1,7 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v6.6.0 | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML (design seul)
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **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
@@ -27,6 +27,16 @@
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
| v6.8.0 | Sites multi-pages + Forms publics ([doc](docs/V68_Sites_Forms.md)) | ✅ | 20 |
| v6.9.0 | Recherche hybride + Ask AI RAG ([doc](docs/V69_Search_Ask_AI.md)) | ✅ | 24 |
| v7.0.0 | Automations multi-étapes + Workers lite ([doc](docs/V70_Automations_Workers.md)) | ✅ | 31 |
| v7.1.0 | Calendar sync + Meeting Notes ([doc](docs/V71_Calendar_Meetings.md)) | ✅ | 15 |
| v7.2.0 | SCIM + 2FA + Audit UI + gouvernance agents ([doc](docs/V72_Enterprise_SCIM_2FA.md)) | ✅ | 52 |
| v7.3.0 | Teamspaces + Verified + Polish + follow-ups ([doc](docs/V73_Wiki_Teamspaces_Polish.md)) | ✅ | 72 |
> **Suites de régression** : `test_v72_enterprise.py` (52) + `test_v73_wiki_polish.py` (72) = **124 verts** · suite complète `-n auto` = **1016 passed**.
> **Follow-ups v7.3 livrés** (voir § v7.3.0 du `ROADMAP.md`): sidebar par teamspace, charts `number`/multi-DB, notif `page.updated` aux followers, unfurl `gitea:`/`github:`, page Settings → Audit — + 21 casses SSO corrigées (install `python3-saml`/`authlib`).
## Blocs Complétés
+219
View File
@@ -0,0 +1,219 @@
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
ID token signature is verified against the issuer JWKS via authlib's JOSE
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
explicitly so the rules are visible and unit-testable.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import secrets
import time
import warnings
import httpx
logger = logging.getLogger(__name__)
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
DEFAULT_OIDC_MAPPING: dict[str, str] = {
"login": "sub",
"email": "email",
"full_name": "name",
"avatar_url": "picture",
"groups": "groups",
}
_DISCOVERY_TTL = 3600.0
_discovery_cache: dict[str, tuple[float, dict]] = {}
class OIDCError(Exception):
"""OIDC processing failure — ``message`` is user-facing."""
def pkce_pair() -> tuple[str, str]:
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
async def discover(issuer_url: str) -> dict:
"""Fetch (and cache) the issuer's OIDC discovery document."""
issuer = issuer_url.rstrip("/")
url = f"{issuer}/.well-known/openid-configuration"
now = time.time()
hit = _discovery_cache.get(issuer)
if hit and now - hit[0] < _DISCOVERY_TTL:
return hit[1]
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
doc = r.json()
except Exception as err:
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
_discovery_cache[issuer] = (now, doc)
return doc
def build_authorize_url(
doc: dict,
*,
client_id: str,
redirect_uri: str,
scope: str,
state: str,
nonce: str,
code_challenge: str,
) -> str:
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": scope or "openid profile email",
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
return doc["authorization_endpoint"] + sep + urlencode(params)
async def exchange_code(
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
) -> dict:
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": code_verifier,
}
auth = None
if client_secret:
auth = (client_id, client_secret)
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
except Exception as err:
raise OIDCError(f"OIDC token request failed: {err}") from err
if r.status_code != 200:
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
try:
tokens = r.json()
except Exception as err:
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
if "error" in tokens:
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
return tokens
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
"""Best-effort userinfo fetch (groups often only live there)."""
endpoint = doc.get("userinfo_endpoint")
if not endpoint or not access_token:
return {}
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
if r.status_code != 200:
return {}
data = r.json()
return data if isinstance(data, dict) else {}
except Exception as err: # userinfo is optional enrichment
logger.debug("userinfo fetch failed: %s", err)
return {}
def validate_id_token(
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
) -> dict:
"""Verify the ID token signature and claims. Returns the claims dict."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from authlib.jose import JsonWebKey
from authlib.jose import jwt as jose_jwt
if isinstance(id_token, bytes):
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
# str — accept both instead of crashing on ``bytes.count(".")``.
id_token = id_token.decode()
if not id_token or id_token.count(".") != 2:
raise OIDCError("Missing or malformed ID token")
try:
keyset = JsonWebKey.import_key_set(jwks)
except Exception as err:
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
try:
# Pick the key matching the token header (kid) when several are offered.
header = json.loads(_b64url_decode(id_token.split(".")[0]))
kid = header.get("kid")
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
token_obj = jose_jwt.decode(id_token, key or keyset)
except Exception as err:
raise OIDCError(f"ID token signature verification failed: {err}") from err
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
now = int(time.time())
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if client_id not in aud_list:
raise OIDCError("ID token audience does not include this client")
exp = claims.get("exp")
if not isinstance(exp, int) or exp < now:
raise OIDCError("ID token expired")
iat = claims.get("iat")
if isinstance(iat, int) and iat > now + 300:
raise OIDCError("ID token issued in the future")
if nonce and claims.get("nonce") != nonce:
raise OIDCError("ID token nonce mismatch")
if not claims.get("sub"):
raise OIDCError("ID token has no subject")
return claims
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
mapping = mapping or DEFAULT_OIDC_MAPPING
identity: dict = {"_raw": claims}
for field in ("login", "email", "full_name", "avatar_url"):
source = mapping.get(field) or field
value = claims.get(source, "")
if isinstance(value, list):
value = value[0] if value else ""
identity[field] = str(value or "").strip()
groups = claims.get(mapping.get("groups", "groups"), [])
if isinstance(groups, str):
groups = [groups]
identity["groups"] = [str(g) for g in groups if g]
if not identity["email"]:
identity["email"] = claims.get("email", "") or ""
if not identity["full_name"]:
identity["full_name"] = claims.get("name", "") or identity["email"]
return identity
+279
View File
@@ -0,0 +1,279 @@
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
dict and builds the SP settings from the ``sso_config`` row.
What the toolkit validates in strict mode (all covered by tests):
XML schema, signature of the assertion and/or the message against the IdP
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
against the AuthnRequest id we pass to ``process_response()`` — combined
with the single-use ``sso_requests`` store that makes replay impossible.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from fastapi import Request
logger = logging.getLogger(__name__)
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
#: NameID; every other value is matched against attribute Name / FriendlyName
#: / URI local part (so ``email`` finds both ``email`` and
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
DEFAULT_SAML_MAPPING: dict[str, str] = {
"login": "nameid",
"email": "nameid",
"full_name": "displayName",
"avatar_url": "avatar",
"groups": "groups",
}
class SAMLError(Exception):
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
@dataclass
class SAMLIdentity:
"""What a validated assertion tells us about the user."""
name_id: str
name_id_format: str = ""
session_index: str = ""
attributes: dict[str, list[str]] = field(default_factory=dict)
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
def resolve(self, source: str) -> str:
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
if not source or source == "nameid":
return self.name_id or ""
if source in self.attributes and self.attributes[source]:
return (self.attributes[source][0] or "").strip()
# FriendlyName match (case-insensitive)
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
if source.lower() in lower and lower[source.lower()]:
return (lower[source.lower()][0] or "").strip()
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
# a mapping of "email" must still find ".../claims/emailaddress".
want = source.lower().lstrip("./")
for name, values in self.attributes.items():
if not values:
continue
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
if local == want or local.endswith(want) or want.endswith(local):
return (values[0] or "").strip()
return ""
def external_base_url(request: Request) -> str:
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
proto = request.headers.get("x-forwarded-proto", "")
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}"
def saml_endpoints(request: Request) -> dict[str, str]:
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
base = external_base_url(request)
return {
"entity_id": f"{base}/auth/saml/metadata",
"acs": f"{base}/auth/saml/callback",
"slo": f"{base}/auth/saml/logout",
"metadata": f"{base}/auth/saml/metadata",
}
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
"""python3-saml settings dict built from a ``sso_config`` row."""
sign_requests = bool(cfg.get("sign_requests"))
sp: dict = {
"entityId": endpoints["entity_id"],
"assertionConsumerService": {
"url": endpoints["acs"],
"binding": BINDING_HTTP_POST,
},
"singleLogoutService": {
"url": endpoints["slo"],
"binding": BINDING_HTTP_REDIRECT,
},
"NameIDFormat": NAMEID_FORMAT_EMAIL,
}
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
sp["privateKey"] = cfg["sp_private_key"]
sp["x509cert"] = cfg["sp_certificate"]
idp: dict = {
"entityId": cfg.get("entity_id") or "",
"singleSignOnService": {
"url": cfg.get("sso_url") or "",
"binding": BINDING_HTTP_REDIRECT,
},
"x509cert": cfg.get("x509_certificate") or "",
}
if cfg.get("slo_url"):
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
return {
"strict": True,
"debug": False,
"sp": sp,
"idp": idp,
"security": {
"authnRequestsSigned": sign_requests,
"logoutRequestSigned": sign_requests,
"logoutResponseSigned": False,
"wantMessagesSigned": False,
"wantAssertionsSigned": True,
"wantNameIdEncrypted": False,
"wantAssertionsEncrypted": False,
"wantXmlValidation": True,
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
"rejectDeprecatedAlgorithm": True,
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
# SP through single-label hosts (http://flowdeck/, docker service
# names). python3-saml rejects those URLs unless this is on.
"allowSingleLabelDomains": True,
},
}
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
https = "on" if external_base_url(request).startswith("https") else "off"
return {
"https": https,
"http_host": request.headers.get("host", "localhost:8080"),
"script_name": script_name,
"request_uri": request.url.path,
"query_string": str(request.url.query or ""),
"get_data": dict(request.query_params),
"post_data": post_data or {},
}
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = build_settings(cfg, saml_endpoints(request))
try:
return OneLogin_Saml2_Auth(
_request_data(request, script_name, post_data=post_data), old_settings=settings
)
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
raise SAMLError(f"Invalid SAML configuration: {err}") from err
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
auth = _auth(request, cfg, "/auth/saml/login")
try:
url = auth.login(return_to=relay_state)
except Exception as err:
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
request_id = auth.get_last_request_id() or ""
if not request_id:
raise SAMLError("AuthnRequest was built without an id")
return url, request_id
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
"""Validate the IdP's SAMLResponse and extract the identity.
``request_id`` is the id of the AuthnRequest we issued (from the
single-use ``sso_requests`` row): the toolkit rejects any response whose
``InResponseTo`` does not match it.
"""
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
try:
auth.process_response(request_id=request_id or None)
except Exception as err:
raise SAMLError(f"SAML response could not be processed: {err}") from err
errors = auth.get_errors()
if errors:
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
if not auth.is_authenticated():
raise SAMLError("SAML response did not authenticate the user")
name_id = auth.get_nameid() or ""
if not name_id:
raise SAMLError("SAML assertion carries no NameID")
return SAMLIdentity(
name_id=name_id,
name_id_format=auth.get_nameid_format() or "",
session_index=auth.get_session_index() or "",
attributes=auth.get_attributes() or {},
friendly_attributes=auth.get_friendlyname_attributes() or {},
)
def metadata_xml(request: Request, cfg: dict) -> str:
"""SP metadata XML (for the IdP configuration screen)."""
from onelogin.saml2.settings import OneLogin_Saml2_Settings
settings = OneLogin_Saml2_Settings(
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
)
try:
xml = settings.get_sp_metadata()
except Exception as err:
raise SAMLError(f"Could not build the SP metadata: {err}") from err
if isinstance(xml, bytes):
xml = xml.decode("utf-8")
return xml
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
auth = _auth(request, cfg, "/auth/saml/logout")
if not cfg.get("slo_url"):
raise SAMLError("The IdP has no Single Logout URL configured")
try:
return auth.logout(
return_to=return_to,
name_id=name_id or None,
session_index=session_index or None,
)
except Exception as err:
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
"""Process a LogoutRequest / LogoutResponse received from the IdP.
``form`` holds the POSTed fields, ``query`` the GET parameters (the
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
Returns ``(redirect_url, errors)``.
"""
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = build_settings(cfg, saml_endpoints(request))
https = "on" if external_base_url(request).startswith("https") else "off"
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
if not post_data:
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
req_data = {
"https": https,
"http_host": request.headers.get("host", "localhost:8080"),
"script_name": "/auth/saml/logout",
"request_uri": request.url.path,
"query_string": str(request.url.query or ""),
"get_data": dict(query),
"post_data": post_data,
}
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
try:
url = auth.process_slo(keep_local_session=True)
except Exception as err:
raise SAMLError(f"SAML logout could not be processed: {err}") from err
return url, auth.get_errors()
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import UTC, datetime
from uuid import uuid4
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
@@ -31,7 +31,7 @@ class SessionManager:
"""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -94,7 +94,7 @@ class SessionManager:
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
)
conn.commit()
except Exception:
pass
logger.exception("_touch_session")
# FastAPI dependency
+23 -4
View File
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
github_oauth_client_id: str = ""
github_oauth_client_secret: str = ""
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = ""
@@ -82,6 +79,26 @@ class Settings(BaseSettings):
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
# SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin
# saves a configuration in Settings → Admin → SSO / Enterprise, the
# `sso_config` table wins (see app/services/sso_provisioning.py).
sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled)
sso_name: str = "Company SSO" # button label on the login page
sso_entity_id: str = "" # SAML: IdP entity id
sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect)
sso_slo_url: str = "" # SAML: IdP Single Logout URL
sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM)
sso_issuer_url: str = "" # OIDC: issuer identifier
sso_client_id: str = "" # OIDC: client id
sso_client_secret: str = "" # OIDC: client secret (env only)
sso_scope: str = "openid profile email"
sso_attribute_mapping: str = "" # JSON, defaults per provider
sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}]
sso_auto_provision: bool = True
sso_only: bool = False # refuse local login when true
sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest
sso_default_workspace_id: int = 0
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True
@@ -109,7 +126,9 @@ class Settings(BaseSettings):
import re
if re.match(r'^[a-zA-Z]:', p):
return Path(p)
return Path("/" + p)
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
return Path("/" + p.lstrip("/"))
return Path("/data/flowdeck.db")
+5
View File
@@ -837,6 +837,11 @@ def get_conn():
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
# l'event loop) reste à migrer module par module.
conn.execute("PRAGMA busy_timeout=5000")
try:
yield conn
finally:
+106 -18
View File
@@ -40,18 +40,28 @@ from app.routers import (
)
from app.routers.api_v2 import router as api_v2_router
from app.routers.api_v2_agent import router as api_v2_agent_router
from app.routers.audit import router as audit_router
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_router
from app.routers.scim import router as scim_router
from app.routers.search_ai import router as search_ai_router
from app.routers.sites import router as sites_router
from app.routers.sso import router as sso_router
from app.routers.web_clipper import api_router as web_clipper_api_router
from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -61,55 +71,107 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _spawn(name: str, factory):
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
Loggue l'exception puis recrée la coroutine 10 s plus tard.
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
le bruit devient un problème.
"""
async def _guard():
while True:
try:
await factory()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
await asyncio.sleep(10)
else:
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
await asyncio.sleep(10)
return asyncio.create_task(_guard())
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
init_webhook_tables()
import os
import secrets
from app.db import get_conn
from app.password_utils import hash_password
admin_hash = hash_password("FlowDeck2026!")
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
(admin_hash,)
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
if settings.app_secret_key == "change-me-to-random":
raise RuntimeError(
"APP_SECRET_KEY non défini — générer une valeur : "
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
)
conn.commit()
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone():
admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12)
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
(hash_password(admin_pw),),
)
conn.commit()
logger.warning(
"Premier démarrage : compte admin créé, mot de passe = %s "
"(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)",
admin_pw,
)
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler())
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
# ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
automation_task = _spawn("automation_scheduler", automation_scheduler)
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler())
backup_task = _spawn("backup_scheduler", backup_scheduler)
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler())
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
from app.services.calendar_sync import calendar_sync_scheduler
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None
if settings.webhook_retry_enabled:
webhook_task = asyncio.create_task(webhook_retry_scheduler())
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try:
yield
finally:
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
if webhook_task is not None:
_tasks = _tasks + (webhook_task,)
for task in _tasks:
@@ -123,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.6.0",
version="7.3.9",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -136,6 +198,7 @@ app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
@@ -171,6 +234,17 @@ app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
app.include_router(search_ai_router)
app.include_router(workers_router)
app.include_router(meetings_router)
# v7.2.0 — enterprise admin
app.include_router(scim_router)
app.include_router(webauthn_router)
app.include_router(audit_router)
app.include_router(governance_router)
# v7.3.0 — teamspaces + verified wiki
app.include_router(wiki_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -260,17 +334,31 @@ async def http_exception_handler(request: Request, exc: _StarHTTPException):
"""
status = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
is_api_v2 = request.url.path.startswith("/api/v2")
# Programmatic API prefixes that must always answer JSON errors instead of
# being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch).
JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn")
is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES)
if status == 404:
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
if "/api" in request.url.path:
if is_json_api and request.url.path.startswith("/scim/v2"):
from fastapi.responses import JSONResponse
return JSONResponse(
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": detail if isinstance(detail, str) else "Not found",
"status": "404"},
status_code=404,
headers={"Content-Type": "application/scim+json"},
)
if "/api" in request.url.path or is_json_api:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
# Non-404: RFC7807 for /api/v2
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
from fastapi.responses import JSONResponse
+17 -1
View File
@@ -16,7 +16,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
# library, gitea_workspace, workspace, workspaces, welcome).
# Ne restent que du machine-to-machine / hors session :
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
# - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+56 -6
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations
import ipaddress
import time
from collections import defaultdict
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/",
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
# API workspace + collections (les endpoints mutants du legacy).
"/scim/v2/", "/workspace/", "/db/",
)
# Pages publiques : seul le non-GET est plafonné (brute force de
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
# visiteurs d'un site publié qui partagent une IP.
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
# Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health",
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"/api/frontend-errors",
})
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
super().__init__(app)
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
self.max_requests = max_requests
self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
self._last_prune = 0.0
self._max_keys = 5000
async def dispatch(self, request: Request, call_next):
path = request.url.path
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if not settings.rate_limit_enabled:
return await call_next(request)
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
# Only rate-limit API routes (+ non-GET sur les pages publiques)
method = request.method.upper()
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
method not in ("GET", "HEAD", "OPTIONS")
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
)
if not limited:
return await call_next(request)
# Exempt health check and error capture
if path in self.EXEMPT_PATHS:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
limit = self.max_requests or settings.rate_limit_requests
ip = self._client_key(request)
now = time.time()
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
self._prune(now)
window_start, count = self._store[ip]
if now - window_start > self.window_seconds:
self._store[ip] = (now, 1)
return await call_next(request)
if count >= self.max_requests:
if count >= limit:
return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
status_code=429,
)
self._store[ip] = (window_start, count + 1)
return await call_next(request)
def _client_key(self, request: Request) -> str:
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
globales, pas seulement RFC1918) — un pair non-global n'est pas un
internaute, donc le XFF du proxy fait foi.
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
exposée directement), prendre la dernière adresse non privée de la
chaîne plutôt que la première.
"""
host = request.client.host if request.client else "unknown"
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
try:
direct = ipaddress.ip_address(host)
if direct.is_private or direct.is_loopback:
return fwd.split(",")[0].strip() or host
except ValueError:
pass # hôte non-IP (testserver…) → on garde la clé d'origine
return host
def _prune(self, now: float) -> None:
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
for k in expired:
del self._store[k]
self._last_prune = now
+538 -1
View File
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
try:
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
except Exception:
pass
logger.exception("_migration_v630_api_v2")
conn.execute(
"""CREATE TABLE IF NOT EXISTS api_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -983,3 +983,540 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_pages_row "
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
)
@register(24, "v6.8.0: Sites & public Forms")
def _migration_sites_forms(conn: sqlite3.Connection) -> None:
"""v6.8.0 — Notion Sites + Forms publics (voir docs/V68_Sites_Forms.md).
``sites`` — mini-site multi-pages (slug, root_page, thème,
domaine custom, password hash, expiry, noindex).
``site_pages`` — arbre public ordonné (site_id, page_id, position).
``site_views`` — compteur de vues jour/site (upsert, pas d'IP brute).
``form_responses`` — log des soumissions anonymes (ip_hash jour, pas d'IP).
``collections.form_config_json`` — config du formulaire public par DB.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'dark',
custom_domain TEXT UNIQUE,
password_hash TEXT DEFAULT '',
expires_at TIMESTAMP,
noindex INTEGER NOT NULL DEFAULT 0,
analytics_id TEXT DEFAULT '',
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE(site_id, page_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_site_pages_site ON site_pages(site_id, position)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_views (
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, day)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS form_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
row_id INTEGER REFERENCES collection_pages(id) ON DELETE SET NULL,
ip_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
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()}
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
)
@register(25, "v6.9.0: semantic search + Ask AI")
def _migration_semantic_search(conn: sqlite3.Connection) -> None:
"""v6.9.0 — hybrid lexical+vector search and RAG Ask AI (docs/V69_* md).
``semantic_embeddings`` — hashed-TF chunk vectors (no external dep):
keyed by (resource_type, resource_id, chunk_id) so both ``page``
and ``collection`` resources are indexed. (Design doc names a
``page_embeddings`` table; the generic key covers collections too.)
``semantic_index_state`` — last indexed timestamp per resource for the
incremental background job.
``pages.search_excluded`` — opt-out flag respected by indexer + search.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS semantic_embeddings (
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
chunk_id INTEGER NOT NULL,
chunk_text TEXT NOT NULL DEFAULT '',
embedding BLOB NOT NULL,
model TEXT NOT NULL DEFAULT 'hash-256',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_type, resource_id, chunk_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sem_emb_res "
"ON semantic_embeddings(resource_type, resource_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS semantic_index_state (
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_type, resource_id)
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
)
@register(26, "v7.0.0: automations v2 (steps) + workers")
def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"""v7.0.0 — multi-step automations + sandboxed workers (docs/V70_* md).
``automation_steps`` — ordered trigger/condition/delay/action chain per
automation. Legacy single trigger+actions columns keep working
(engine falls back when an automation has no steps).
``automations.trigger_mode`` — ``any`` (default) or ``all`` (every
trigger event must arrive within a 5-minute window).
``workers`` / ``worker_runs`` — custom Python snippets (cron/manual),
shareable across the team, with execution logs + daily budget.
``collection_properties.button_automation_id`` — native DB button cells.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automation_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asteps_auto "
"ON automation_steps(automation_id, position)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
code_py TEXT NOT NULL DEFAULT '',
schedule_cron TEXT DEFAULT '',
shared INTEGER NOT NULL DEFAULT 0,
daily_budget_s INTEGER NOT NULL DEFAULT 60,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS worker_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
status TEXT NOT NULL,
logs TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"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()}
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()}
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
"INTEGER REFERENCES automations(id) ON DELETE SET NULL"
)
@register(27, "v7.1.0: calendar sync + meeting transcripts")
def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"""v7.1.0 — external calendar sync + AI meeting notes (docs/V71_* md).
``calendar_links`` — per-user link between a collection and an external
calendar (google REST / generic caldav), tokens Fernet-encrypted.
``meeting_transcripts`` — uploaded audio + transcript + AI summary per page.
``collection_pages.external_event_id`` — remote event id for push/pull
matching and conflict detection.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS calendar_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
calendar_id TEXT NOT NULL DEFAULT 'primary',
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
date_property TEXT DEFAULT '',
sync_token TEXT DEFAULT '',
last_sync TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, provider, calendar_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_transcripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
audio_path TEXT NOT NULL DEFAULT '',
transcript TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'fr',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"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()}
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_cp_external "
"ON collection_pages(collection_id, external_event_id)"
)
@register(28, "v7.2.0: SCIM + 2FA + audit UI + agent governance")
def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"""v7.2.0 — enterprise admin (docs/V72_* md).
``scim_tokens`` — Bearer tokens for SCIM provisioning (admin-managed).
``domain_claims`` — DNS/well-known verified domains + SSO enforcement.
``webauthn_credentials`` — passkeys (credential_id, COSE public key).
``agent_policies`` — per-workspace tool scope + approval gate.
``agent_approvals`` — approval queue for gated write actions.
``users.totp_secret_enc`` / ``totp_backup_hashes`` — TOTP 2FA.
(``users.is_active`` already exists — used by SCIM suspend.)
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS scim_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id),
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
txt_token TEXT NOT NULL DEFAULT '',
verified INTEGER NOT NULL DEFAULT 0,
auto_join_role TEXT NOT NULL DEFAULT 'viewer',
enforce_sso INTEGER NOT NULL DEFAULT 0,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS agent_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
allowed_tools_json TEXT,
max_steps INTEGER NOT NULL DEFAULT 12,
require_approval INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS agent_approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL DEFAULT 0,
tool TEXT NOT NULL DEFAULT '',
args_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
requester_id INTEGER REFERENCES users(id),
approver_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"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()}
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:
conn.execute("ALTER TABLE users ADD COLUMN totp_backup_hashes TEXT DEFAULT '[]'")
@register(29, "v7.3.0: teamspaces + verified pages + collab polish")
def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""v7.3.0 — teamspaces, verified pages, collab polish (docs/V73_*.md).
``teamspaces`` / ``teamspace_members`` — namespaces for pages + databases;
``private=1`` hides a teamspace from non-members (404, like restricted
collections). ``page_verifications`` — ✅ badge with expiry.
``comment_reactions`` / ``page_follows`` — collab polish.
``guest_shares`` — account-less page access via ``/g/<token>``.
``page_views`` — daily counters, same pattern as ``site_views``.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS teamspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
private INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, name)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS teamspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'editor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(teamspace_id, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_teamspace_members_user "
"ON teamspace_members(user_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_verifications (
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
verified_by INTEGER REFERENCES users(id),
note TEXT NOT NULL DEFAULT '',
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_page_verifications_expiry "
"ON page_verifications(expires_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS comment_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(comment_id, user_id, emoji)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_follows (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_id, user_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS guest_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
email TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'viewer',
created_by INTEGER REFERENCES users(id),
expires_at TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_views (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (page_id, day)
)
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
@register(23, "v6.7.0: SSO/SAML enterprise auth")
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
``sso_config`` — single active SSO provider (SAML or OIDC), managed
from Settings → Admin → SSO / Enterprise. Secrets
(``client_secret``, SP private key) are encrypted at
rest by ``app.services.sso_provisioning``.
``sso_login_history`` — audit trail of every SSO login attempt (successes
AND rejections — signature failure, replay, no
local account…).
``sso_requests`` — single-use anti-replay store: AuthnRequest ids and
OIDC states, CSRF relay tokens, PKCE verifiers and
the post-login redirect target. One row is consumed
by exactly one callback.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL DEFAULT 'saml',
name TEXT NOT NULL DEFAULT 'Company SSO',
entity_id TEXT NOT NULL DEFAULT '',
sso_url TEXT NOT NULL DEFAULT '',
slo_url TEXT DEFAULT '',
x509_certificate TEXT NOT NULL DEFAULT '',
issuer_url TEXT DEFAULT '',
client_id TEXT DEFAULT '',
client_secret TEXT DEFAULT '',
scope TEXT DEFAULT 'openid profile email',
attribute_mapping TEXT NOT NULL DEFAULT '{}',
groups_mapping TEXT NOT NULL DEFAULT '[]',
auto_provision INTEGER NOT NULL DEFAULT 1,
sso_only INTEGER NOT NULL DEFAULT 0,
sign_requests INTEGER NOT NULL DEFAULT 0,
default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
sp_private_key TEXT DEFAULT '',
sp_certificate TEXT DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL,
provider_name TEXT NOT NULL DEFAULT 'SSO',
sso_identifier TEXT,
ip_address TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
success INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sso_history_user "
"ON sso_login_history(user_id, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_requests (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
relay_state TEXT NOT NULL DEFAULT '',
code_verifier TEXT NOT NULL DEFAULT '',
next_path TEXT NOT NULL DEFAULT '/workspaces',
used INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
)
+44 -24
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall()
now = datetime.utcnow()
now = datetime.now(UTC).replace(tzinfo=None)
for trig in triggers:
last = trig["last_fired_at"]
if last:
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int | None:
async def _current_user_id(request: Request) -> int:
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
user = await get_current_user(request)
if user and user.get("id"):
return user["id"]
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else None
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
async def _workspace_id(request: Request) -> int | None:
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
async def _current_admin(request: Request) -> dict:
"""Require an admin session. Falls back to the single admin row, matching
the agent router's unauthenticated convention (single-user deployments)."""
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
`ping()` vers un `api_base` de son choix = SSRF)."""
user = await get_current_user(request)
if user:
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return dict(row)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
def _default_agent(conn, user_id: int) -> dict:
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
return {"ok": False, "provider": provider, "error": str(exc)}
def _check_api_base(value: str) -> str:
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
settings `llm_allow_private=false`.
"""
url = (value or "").strip()
if not url:
return ""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
return url
@router.patch("/providers")
async def update_provider_config(request: Request):
await _current_admin(request)
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
provider=provider or None,
model=(body.get("model") or "").strip() or None,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
clear_keys=(provider == "offline"),
)
llm = LLMClient()
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
llm = LLMClient(
provider=provider,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
)
try:
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
+28 -24
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
async def _require_session_or_bearer(request: Request) -> None:
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
if request.url.path in _API_PUBLIC_PATHS:
return
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
return
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
from app.routers.public_api import verify_token
verify_token(auth)
return
raise HTTPException(401, "Authentication required")
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
# ── Simple rate limiter (in-memory, per-IP) ──
_rate_limit_store: dict[str, tuple[float, int]] = {}
@@ -26,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
now = datetime.utcnow().timestamp()
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60:
_rate_limit_store[ip] = (now, 1)
@@ -47,12 +67,12 @@ async def health(request: Request):
conn.execute("SELECT 1")
db_ok = True
except Exception:
pass
logger.exception("health")
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
pass
logger.exception("health")
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
@@ -79,22 +99,6 @@ async def stats():
}
@router.get("/projects")
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
"""List Gitea projects (JSON)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception:
repos = []
return {"projects": repos}
@router.post("/move")
async def move_card(
request: Request,
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
"comments": comments,
"checklists": checklists,
}
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("card_detail.html")
return HTMLResponse(template.render(**ctx))
+37 -42
View File
@@ -165,7 +165,7 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
try:
d[k] = json.loads(d[k] or "{}")
except Exception:
pass
logger.exception("get_me")
# never expose secrets
return d
@@ -261,7 +261,7 @@ async def create_workspace(request: Request, authorization: str | None = Header(
try:
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
except Exception:
pass
logger.exception("create_workspace")
conn.commit()
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
audit_log(user, "workspace.create", "workspace", wid, name, request)
@@ -497,17 +497,13 @@ async def create_collection_v2(request: Request, authorization: str | None = Hea
with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
cid = cur.lastrowid
# materialize properties if schema provided
try:
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
except Exception:
pass
# materialize properties if schema provided — A25 : PAS de try ici,
# une exception doit interrompre la transaction (sinon la collection est
# commitée sans son schéma et l'erreur disparaît).
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
# default view
try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
except Exception:
pass
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request)
@@ -593,20 +589,20 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
try:
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
except Exception:
pass
logger.exception("create_linked_db")
# copy views + properties (light)
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
for p in rows:
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
except Exception:
pass
logger.exception("create_linked_db")
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
for v in vrows:
try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
except Exception:
pass
logger.exception("create_linked_db")
conn.commit()
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
return {"id": nid, "name": name, "status": "created"}
@@ -766,7 +762,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
pass
# light validation: we rely on existing validators
except Exception:
pass
logger.exception("create_collection_page_v2")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
# apply auto props
try:
@@ -774,7 +770,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, prop_vals, user, is_create=True)
except Exception:
pass
logger.exception("create_collection_page_v2")
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
pid = cur.lastrowid
conn.commit()
@@ -783,7 +779,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
except Exception:
pass
logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
@@ -857,14 +853,14 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, stored, user, is_create=False)
except Exception:
pass
logger.exception("patch_page_v2")
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
except Exception:
pass
logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}")
@@ -883,7 +879,7 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
except Exception:
pass
logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore")
@@ -901,7 +897,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.restored", {"page_id": page_id})
except Exception:
pass
logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"}
raise HTTPException(404, "Page not found or not deleted")
@@ -1109,7 +1105,7 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
except Exception:
pass
logger.exception("create_relation_v2")
conn.commit()
return {"id": prop_id, "status": "updated"}
@@ -1184,7 +1180,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
except Exception:
pass
logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@router.patch("/views/{view_id}")
@@ -1208,7 +1204,7 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
try:
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
except Exception:
pass
logger.exception("patch_view_v2")
# also flat keys
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
if k in body:
@@ -1350,7 +1346,7 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
except Exception:
pass
logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}")
@@ -1378,7 +1374,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}")
@@ -1419,13 +1415,13 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
created += 1
except Exception:
pass
logger.exception("create_mention_v2")
conn.commit()
if created:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
except Exception:
pass
logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"}
# ── Notifications ─────────────────────────────────────────────────────────
@@ -1522,7 +1518,7 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
except Exception:
pass
logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}")
@@ -1537,7 +1533,7 @@ async def remove_favorite_v2(page_id: int, request: Request, authorization: str
try:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
except Exception:
pass
logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"}
@router.get("/tags")
@@ -1687,7 +1683,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
except Exception:
pass
logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"}
@router.patch("/shares/{share_id}")
@@ -1748,7 +1744,7 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page_v2")
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
@@ -1763,7 +1759,7 @@ async def unpublish_page_v2(page_id: int, request: Request, authorization: str |
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page_v2")
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1844,7 +1840,7 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
except Exception:
pass
logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}")
@@ -1871,7 +1867,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
except Exception:
pass
logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"}
@router.delete("/sprints/{sprint_id}")
@@ -1937,7 +1933,7 @@ async def burndown_v2(sprint_id: int, request: Request, authorization: str | Non
completed += 1
break
except Exception:
pass
logger.exception("burndown_v2")
remaining = total - completed
# ideal linear
ideal = [round(total * (1 - i / 10)) for i in range(11)]
@@ -2047,11 +2043,10 @@ async def apply_db_template_v2(template_id: int, request: Request, authorization
schema = json.loads(tpl["schema_json"] or "[]")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
cid = cur.lastrowid
try:
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
except Exception:
pass
# A25 : pas de try — un échec de matérialisation doit interrompre la
# transaction plutôt que de commiter une collection sans schéma.
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
conn.commit()
return {"collection_id": cid, "name": name, "status": "created"}
+124
View File
@@ -0,0 +1,124 @@
"""FlowDeck — unified audit log API (v7.2.0).
Merges ``api_audit_log`` + ``permission_audit_log`` + ``sso_login_history``
with actor/resource/date filters and CSV export (10k rows max, 365-day
retention note). Admin only. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.api_v2_helpers import (
has_scope,
parse_pagination,
resolve_bearer_token,
)
router = APIRouter(tags=["audit"])
def _admin_user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
(sess.get("id"),)).fetchone()
if row and row["is_admin"]:
return sess
raise HTTPException(403, "Admin required")
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if user and user.get("is_admin") and has_scope(
user.get("_token_scopes") or "read", "admin"):
return user
raise HTTPException(401, "Admin authentication required")
def _query(source: str, actor: str, action: str, limit: int, offset: int):
"""One source query → (rows, columns). All normalized to a common shape."""
with get_conn() as conn:
if source in ("api", "all"):
rows = conn.execute(
"""SELECT created_at AS at, user_id AS actor, action,
resource_type || ':' || resource_id AS resource,
ip_address AS ip, detail, 'api' AS source
FROM api_audit_log
WHERE (?='' OR CAST(user_id AS TEXT)=?)
AND (?='' OR action LIKE ?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
).fetchall()
if source == "api":
return rows
api = [dict(r) for r in rows]
else:
api = []
if source in ("permissions", "all"):
rows = conn.execute(
"""SELECT created_at AS at, performed_by AS actor, action,
resource_type || ':' || resource_id AS resource,
ip_address AS ip,
('target=' || COALESCE(target_user_id, target_group_id, '')
|| ' ' || COALESCE(old_role,'') || '→' || COALESCE(new_role,'')) AS detail,
'permissions' AS source
FROM permission_audit_log
WHERE (?='' OR CAST(performed_by AS TEXT)=?)
AND (?='' OR action LIKE ?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
).fetchall()
if source == "permissions":
return rows
perm = [dict(r) for r in rows]
else:
perm = []
if source in ("sso", "all"):
rows = conn.execute(
"""SELECT created_at AS at, user_id AS actor,
('sso_' || provider_type || '_' ||
CASE success WHEN 1 THEN 'success' ELSE 'failure' END) AS action,
provider_name AS resource, ip_address AS ip,
COALESCE(error_message, sso_identifier, '') AS detail,
'sso' AS source
FROM sso_login_history
WHERE (?='' OR CAST(user_id AS TEXT)=?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, limit, offset)).fetchall()
if source == "sso":
return rows
sso = [dict(r) for r in rows]
else:
sso = []
merged = sorted(api + perm + sso, key=lambda d: str(d.get("at") or ""),
reverse=True)
return merged[:limit]
@router.get("/api/v2/audit/logs")
async def audit_logs(request: Request):
_admin_user(request)
qp = request.query_params
source = (qp.get("source") or "all").lower()
if source not in ("all", "api", "permissions", "sso"):
raise HTTPException(400, "source must be all|api|permissions|sso")
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
if qp.get("format") == "csv":
import csv
import io
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
"resource", "ip", "detail"])
writer.writeheader()
for r in rows[:10000]:
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition":
"attachment; filename=audit.csv"})
return JSONResponse(content={"logs": rows, "source": source,
"limit": limit, "offset": offset})
+176 -6
View File
@@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
.oauth-btn:hover{background:#333;}
.sso-btn{border-color:rgba(35,131,226,.5);}
</style>
</head>
<body>
@@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
</form>
<div class="oauth-section">
<div class="oauth-section" id="oauth-section">
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
</div>
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
<div class="oauth-section" id="sso-section" style="display:none">
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<div id="sso-buttons"></div>
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
</div>
</div>
<script>
// Show session expired banner if ?expired=1 in URL
@@ -101,6 +108,38 @@ let mode='login';
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
(async function loadSsoProviders(){
try{
const r = await fetch('/api/v2/sso/providers');
if(!r.ok) return;
const d = await r.json();
const providers = d.providers || [];
if(!providers.length) return;
const wrap = document.getElementById('sso-buttons');
providers.forEach(function(p){
const b = document.createElement('button');
b.className = 'oauth-btn sso-btn';
b.style.marginBottom = '8px';
b.title = 'Sign in with ' + (p.name || 'SSO');
b.onclick = function(){ window.location = p.login_url; };
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
const label = document.createElement('span');
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
b.appendChild(icon); b.appendChild(label);
wrap.appendChild(b);
});
document.getElementById('sso-section').style.display = 'block';
if(d.sso_only){
// Local auth is refused server-side too — don't show a dead form.
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
}
}catch(e){}
})();
</script>
</body>
</html>"""
@@ -191,6 +230,16 @@ async def register(request: Request):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
# SSO-only instance (v6.7.0): local registration is refused — accounts are
# auto-provisioned by the IdP instead (admins still come from Settings).
from app.services.sso_provisioning import is_sso_only
if is_sso_only():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": "Registration is disabled — sign in with your organization SSO"},
status_code=403,
)
with get_conn() as conn:
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
if existing:
@@ -260,13 +309,41 @@ async def local_login(request: Request):
conn.commit()
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
# Successful login
# Successful local login — SSO-only instances keep a way in for admins
# only (every other account must use the IdP, design §7.1).
from app.services.sso_provisioning import is_sso_only
if is_sso_only() and not ud.get("is_admin"):
return JSONResponse(
{"error": "Local login is disabled on this instance — sign in with SSO"},
status_code=403,
)
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
if not ud.get("is_admin"):
with get_conn() as conn:
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
if dom:
enforced = conn.execute(
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
" AND enforce_sso=1", (dom,)).fetchone()
if enforced:
return JSONResponse(
{"error": "Local login is disabled for your domain — sign in with SSO"},
status_code=403)
with get_conn() as conn:
conn.execute(
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
(str(time.time()), ud["id"]),
)
conn.commit()
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
from app.services import two_factor as _2fa
if _2fa.is_enabled(ud["id"]):
return JSONResponse({"status": "2fa_required",
"pending": _2fa.mint_pending(ud["id"])})
session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
@@ -373,9 +450,21 @@ async def callback(
@router.get("/logout")
async def logout():
"""Clear session and redirect to login page."""
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
async def logout(request: Request):
"""Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one
is configured (the actual cookie clearing happens on the SLO route).
"""
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
local_target = "/auth/login?provider=local"
if user and user.get("_sso_name_id"):
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
response = RedirectResponse(url=local_target, status_code=302)
response.delete_cookie("flowdeck_session")
return response
@@ -389,6 +478,87 @@ async def current_user(request: Request):
return {"authenticated": False}
return {"authenticated": True, "user": user}
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
@router.post("/local-verify")
async def local_verify(request: Request):
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
from fastapi.responses import JSONResponse
from app.db import get_conn
from app.services import two_factor as _2fa
try:
body = await request.json()
except Exception:
body = {}
user_id = _2fa.redeem_pending(body.get("pending", ""))
if not user_id:
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
if not _2fa.verify_code(user_id, body.get("code", "")):
return JSONResponse({"error": "Invalid code"}, status_code=401)
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not row or not row["is_active"]:
return JSONResponse({"error": "Account disabled"}, status_code=403)
ud = dict(row)
session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
response.set_cookie("flowdeck_session", session, httponly=True,
max_age=86400 * 7, samesite="lax", path="/")
return response
def _session_user_or_401(request: Request) -> dict:
from fastapi import HTTPException
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
@router.get("/2fa/status")
async 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"]),
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
@router.post("/2fa/setup")
async 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"])
@router.post("/2fa/activate")
async def twofa_activate(request: Request):
from fastapi.responses import JSONResponse
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
try:
body = await request.json()
except Exception:
body = {}
try:
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
body.get("code", ""))
except ValueError:
return JSONResponse({"error": "Invalid code — secret not activated"},
status_code=400)
return {"status": "enabled", "backup_codes": codes}
@router.post("/2fa/disable")
async def twofa_disable(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
_2fa.disable(user["id"])
return {"status": "disabled"}
# ── Helpers ──
def _log_login(user_id: int, request: Request):
"""Record login in history."""
@@ -403,4 +573,4 @@ def _log_login(user_id: int, request: Request):
)
conn.commit()
except Exception:
pass
logger.exception("_log_login")
+153 -4
View File
@@ -4,14 +4,28 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import get_page_context, run_automation
from app.services.automations import (
get_page_context,
get_steps,
press_button,
run_automation,
validate_step,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
def _require_session(request: Request) -> None:
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(status_code=401, detail="Authentication required")
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
TRIGGER_TYPES = ("event", "cron", "button")
@@ -69,7 +83,7 @@ async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
user = _current_user(request)
by = user.get("id") or 1
by = user["id"]
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO automations
@@ -172,3 +186,138 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
STEP_SECRET_FIELDS = {"webhook_url"}
def _require_session(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _get_auto(auto_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
return dict(row) if row else None
def _auto_404():
# NOTE: return (not raise) — the global 404 handler redirects non-/api
# paths to /workspaces, which TestClient follows into a 200.
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Automation not found"}, status_code=404)
def _encrypt_step_config(config: dict) -> dict:
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
from app.services.sso_provisioning import encrypt_secret
cfg = dict(config or {})
for field in STEP_SECRET_FIELDS:
if field in cfg and cfg[field]:
val = str(cfg[field])
if not val.startswith("gAAAAA"):
cfg[field] = encrypt_secret(val)
return cfg
@router.get("/workspace/automations/{auto_id}/steps")
async 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)}
@router.post("/workspace/automations/{auto_id}/steps")
async def create_step(request: Request, auto_id: int):
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
kind = body.get("kind", "")
config = body.get("config", {}) or {}
validate_step(kind, config)
with get_conn() as conn:
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
(auto_id,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
" VALUES (?,?,?,?)",
(auto_id, kind, int(body.get("position", pos)),
json.dumps(_encrypt_step_config(config))))
conn.commit()
step_id = cur.lastrowid
return {"id": step_id, "status": "created"}
@router.put("/workspace/automations/steps/{step_id}")
async def update_step(request: Request, step_id: int):
_require_session(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
if not row:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Step not found"}, status_code=404)
kind = body.get("kind", row["kind"])
try:
config = body.get("config", json.loads(row["config_json"] or "{}"))
except (TypeError, json.JSONDecodeError):
config = {}
validate_step(kind, config if isinstance(config, dict) else {})
conn.execute(
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
(kind, int(body.get("position", row["position"])),
json.dumps(_encrypt_step_config(config)), step_id))
conn.commit()
return {"id": step_id, "status": "updated"}
@router.delete("/workspace/automations/steps/{step_id}")
async 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,))
conn.commit()
return {"id": step_id, "status": "deleted"}
@router.put("/workspace/automations/{auto_id}/mode")
async def set_trigger_mode(request: Request, auto_id: int):
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
mode = (body.get("mode") or "any").lower()
if mode not in ("any", "all"):
raise HTTPException(status_code=400, detail="mode must be any or all")
with get_conn() as conn:
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
conn.commit()
return {"id": auto_id, "trigger_mode": mode}
@router.post("/api/automations/press-button")
async def press_button_endpoint(request: Request):
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
body = await request.json() if request.headers.get("content-type") else {}
try:
collection_id = int(body.get("collection_id", 0))
row_id = int(body.get("row_id", 0))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
prop_ref = body.get("property", body.get("property_id", ""))
if not prop_ref:
raise HTTPException(status_code=400, detail="property required")
user = _current_user(request)
try:
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
return result
+98 -34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
@@ -694,7 +695,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception:
pass
logger.exception("_sidebar_data")
elif ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -780,7 +781,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name,
@@ -803,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user),
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
"sidebar_config": get_sidebar_config_sync(uid)}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -907,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
@router.get("/library", response_class=HTMLResponse)
async 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 jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, owner, repo)
# Load all pages for the workspace from DB
ws_key = f"{owner}/{repo}" if owner and repo else ""
@@ -981,7 +982,7 @@ async def add_favorite(request: Request, page_id: int):
try:
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("add_favorite")
return {"status": "added", "page_id": page_id}
@@ -996,7 +997,7 @@ async def remove_favorite(request: Request, page_id: int):
try:
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id}
# ═══════════ Share API ═══════════
@@ -1031,7 +1032,7 @@ async def publish_page(request: Request, page_id: int):
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@@ -1047,7 +1048,7 @@ async def unpublish_page(request: Request, page_id: int):
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page")
return {"is_published": False}
@@ -1068,7 +1069,7 @@ async def restore_page(request: Request, page_id: int):
try:
await fire_event("page.restored", {"page_id": page_id})
except Exception:
pass
logger.exception("restore_page")
return {"status": "ok", "restored": page_id}
@@ -1083,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("trash.html")
return template.render(**_sidebar_data(request))
@@ -1212,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[],
@@ -1239,8 +1240,8 @@ async def board_view(
logger.error("Board view error: %s", e)
cards = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
# Dynamic groups from Gitea labels (fallback to hardcoded)
group_names = ["Design", "Engineering", "No Team"]
@@ -1384,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""),
parent_id: int = Query(default=0)):
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
# A7 : la création de page exige une session (route sortue de la liste CSRF).
raise HTTPException(401, "Authentication required")
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
page_title = title.strip() if title else ""
try:
@@ -1416,11 +1420,11 @@ async def create_page(request: Request, title: str = Query(default=""),
async def get_page(request: Request, page_id: int):
"""Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
# No session → legacy single-user behaviour (matches collections `_require_view`).
if user and user.get("id"):
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -1456,7 +1460,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown"})
"content_format": content_format or "markdown",
"actor_id": user.get("id")})
return {"status": "ok"}
@@ -1522,7 +1527,8 @@ async def save_page_blocks(request: Request, page_id: int):
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"})
"content_format": "blocks",
"actor_id": uid})
return {"status": "ok", "id": page_id}
@@ -1726,7 +1732,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
@@ -1883,7 +1889,11 @@ async def import_file(request: Request):
@router.post("/api/og/metadata")
async def og_metadata(request: Request):
"""v5.5.0: Open Graph metadata for a bookmark card."""
"""v5.5.0: Open Graph metadata for a bookmark card.
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
unfurled straight from the forge API (no HTTP fetch of the HTML page).
"""
try:
body = await request.json()
except Exception:
@@ -1891,11 +1901,62 @@ async def og_metadata(request: Request):
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
m = _REPO_REF_RE.match(url)
if m:
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
data = await _unfurl_repo(forge, owner, repo)
if data:
return {"ok": True, **data}
from app.services.og_fetcher import fetch_og_metadata
data = await fetch_og_metadata(url)
try:
data = await fetch_og_metadata(url)
except ValueError as exc:
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
raise HTTPException(400, str(exc)) from None
return {"ok": True, **data}
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
async def _unfurl_repo(forge: str, owner: str, repo: str):
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
try:
if forge == "gitea":
from app.services.gitea_client import GiteaClient
info = await GiteaClient().get_repo_info(owner, repo)
site = "Gitea"
else:
from app.config import settings
from app.services.github_adapter import GitHubAdapter
token = getattr(settings, "github_token", None) or ""
if token:
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
else:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers={"Accept": "application/vnd.github+json"},
)
r.raise_for_status()
info = r.json()
site = "GitHub"
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
return None
branch = info.get("default_branch") or "main"
return {
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
"title": info.get("full_name") or f"{owner}/{repo}",
"description": (info.get("description") or f"{site} repository "
f"{owner}/{repo} · default branch: {branch}"),
"image": "",
"site_name": site,
"language": info.get("language") or "",
}
@router.post("/api/embed/resolve")
async def resolve_embed(request: Request):
"""v5.5.0: rewrite a pasted URL to its provider embed src.
@@ -1979,7 +2040,7 @@ async def delete_page(request: Request, page_id: int):
if not row:
raise HTTPException(404, "Page not found")
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
return {"status": "ok", "deleted": page_id, "title": row["title"]}
@@ -1990,8 +2051,8 @@ async 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"
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
# v6.0.0: granular page permissions — hide restricted pages (404).
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
@@ -2084,12 +2145,15 @@ async def sync_project(owner: str, repo: str):
if board:
board_id = board["id"]
columns = json.loads(board["columns_json"])
# A23 : un seul executemany pour toutes les cards.
conn.executemany(
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
[
(board_id, issue["number"], _issue_column(issue, columns, board_id))
for issue in issues_only
],
)
for issue in issues_only:
col = _issue_column(issue, columns, board_id)
conn.execute(
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
(board_id, issue["number"], col),
)
# Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit()
+9 -3
View File
@@ -107,6 +107,12 @@ async def add_comment(request: Request, page_id: int):
comment_id = cur.lastrowid
conn.commit()
# v7.3.0: commenting implies following — the author gets the
# (throttled) page.updated notifications like any other follower.
from app.services import wiki as wiki_svc
wiki_svc.ensure_follow(page_id, uid, conn=conn)
conn.commit()
# Notify users @-mentioned in the comment (skip the author).
url = _page_url(page_id)
title = f"New comment on “{page['title']}”"
@@ -123,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
except Exception:
pass
logger.exception("add_comment")
return {"id": comment_id, "status": "created"}
@@ -153,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
except Exception:
pass
logger.exception("notify_page_mentions")
return {"mentioned": mentioned}
@@ -184,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"id": comment_id, "status": "updated"}
+185 -26
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
from __future__ import annotations
import html as _htmlmod
import json
import logging
import sqlite3
@@ -42,23 +43,22 @@ def _session_user(request: Request) -> dict | None:
def _require_view(collection_id: int, user: dict | None) -> None:
"""Return None when a user may view the collection, else raise 404.
"""Raise 404 when the user may not view the collection (404 hides it).
A missing/userless session keeps the legacy single-user behaviour (owner on
un-workspaced collections); explicit ``restricted`` / ``private`` collections
are hidden for non-owners unless granted.
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
"""
if not user:
return
raise HTTPException(status_code=404, detail="Collection not found")
pm = PermissionManager(user["id"])
if not pm.can_view_collection(collection_id):
raise HTTPException(status_code=404, detail="Collection not found")
def _require_edit(collection_id: int, user: dict | None) -> None:
"""Raise 403 when the user may not edit pages in the collection."""
"""Raise 401/403 when the user may not edit pages in the collection."""
if not user:
return
raise HTTPException(status_code=401, detail="Authentication required")
pm = PermissionManager(user["id"])
if not pm.can_edit_collection(collection_id):
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
@@ -373,20 +373,35 @@ async def duplicate_collection_api(request: Request, collection_id: int):
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for p in rows:
ncur = conn.execute(
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
tuples = [
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
None, p["reverse_name"], None, None, p["rollup_function"],
p["formula_expression"], p["position"], p["required"],
p["visible_in_views"])
for p in rows
]
if tuples:
ncur = conn.executemany(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
None, p["reverse_name"], None, None, p["rollup_function"],
p["formula_expression"], p["position"], p["required"],
p["visible_in_views"]),
tuples,
)
prop_map[p["id"]] = ncur.lastrowid
new_ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
(new_id,),
).fetchall()
]
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
for p, new_pid in zip(rows, new_ids, strict=True):
prop_map[p["id"]] = new_pid
# Fix cross-property references after all rows exist (creates may target
# columns not inserted yet). Related collection remapped to the copy.
@@ -1898,6 +1913,71 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
# ── {collection_id} wildcards (LAST — catches everything else) ──
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
async 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
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
point at *any* database (the dashboard's own collection is the default),
which is what "dashboards multi-DB" means.
"""
uid = _session_user(request)
_require_view(collection_id, uid)
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
(dashboard_id, collection_id)).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
layout = json.loads(dash["layout_json"] or "{}")
columns = max(1, int(layout.get("columns", 1) or 1))
widgets = layout.get("widgets", []) or []
if not isinstance(widgets, list):
widgets = []
rendered = []
for w in widgets[:40]:
if not isinstance(w, dict):
continue
wc = int(w.get("collection_id") or 0) or collection_id
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
if not coll:
continue
try:
_require_view(wc, uid)
except HTTPException:
continue # restricted database → widget skipped, not rendered
with get_conn() as conn:
wpages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
(wc, CHART_MAX_GROUPS)).fetchall()
wconfig = {k: v for k, v in w.items()
if k in ("chart_type", "chart_property", "aggregate", "title")}
view_type = w.get("view_type") or "chart"
if view_type == "chart":
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
else:
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
width = int(w.get("width") or 0)
span = f"grid-column: span {width};" if width and width > 0 else ""
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
body = f"""
<style>
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
</style>
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
"""
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
@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"):
@@ -1912,9 +1992,14 @@ async def view_collection(request: Request, collection_id: int, view_type: str =
raise HTTPException(status_code=404, detail="Collection not found")
view = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
(collection_id,),
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
(collection_id, view_type),
).fetchone()
if not view:
view = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
(collection_id,),
).fetchone()
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
@@ -2188,27 +2273,101 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
# ── v4.3.0: New view types ──
# Multi-collection dashboards and chart aggregations cap the number of
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
CHART_MAX_GROUPS = 200
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
values: list[float] = []
for p in pages[:CHART_MAX_GROUPS]:
props = json.loads(p.get("property_values_json", "{}") or "{}")
v = props.get(chart_property)
if v is None or v == "":
continue
try:
values.append(float(v))
except (ValueError, TypeError):
continue
return values
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
"""Compute count|sum|avg|min|max over a property (or row count)."""
values = _chart_values(pages, chart_property)
if aggregate == "count":
return float(len(pages[:CHART_MAX_GROUPS]))
if not values:
return 0.0
if aggregate == "sum":
return float(sum(values))
if aggregate == "avg":
return float(sum(values) / len(values))
if aggregate == "min":
return float(min(values))
if aggregate == "max":
return float(max(values))
return 0.0
def _fmt_number(value: float) -> str:
if abs(value) >= 1e9:
return f"{value / 1e9:.2f}B"
if abs(value) >= 1e6:
return f"{value / 1e6:.2f}M"
if abs(value) >= 1e3:
return f"{value / 1e3:.1f}K"
if value == int(value):
return str(int(value))
return f"{value:.2f}"
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN."""
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
chart_type = config.get("chart_type", "bar")
chart_property = config.get("chart_property", "")
if chart_type == "number":
aggregate = config.get("aggregate", "sum" if chart_property else "count")
if aggregate not in ("count", "sum", "avg", "min", "max"):
aggregate = "sum" if chart_property else "count"
num = _chart_aggregate(pages, chart_property, aggregate)
label = config.get("title") or chart_property or collection["name"]
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
</style>
<div class="kpi">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{_fmt_number(num)}</div>
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
{' of ' + chart_property if chart_property else ''}</div>
</div>
""")
labels = []
values = []
for p in pages:
labels.append(p["title"][:30])
props = json.loads(p.get("property_values_json", "{}"))
val = 0
for p in pages[:CHART_MAX_GROUPS]:
labels.append(str(p.get("title") or "")[:30])
props = json.loads(p.get("property_values_json", "{}") or "{}")
val = 0.0
if chart_property:
v_raw = props.get(chart_property, 0)
try:
val = float(v_raw) if v_raw else 0
val = float(v_raw) if v_raw else 0.0
except (ValueError, TypeError):
val = 0
values.append(val if val else 1)
val = 0.0
values.append(val)
labels_json = json.dumps(labels)
values_json = json.dumps(values)
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
@@ -2223,7 +2382,7 @@ new Chart(document.getElementById('chartCanvas'), {{
data: {{
labels: {labels_json},
datasets: [{{
label: '{collection["name"]}',
label: '{config.get("title") or collection["name"]}',
data: {values_json},
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
}}]
@@ -2231,7 +2390,7 @@ new Chart(document.getElementById('chartCanvas'), {{
options: {{ responsive: true }}
}});
</script>
<p class="desc">{len(pages)} entries</p>
<p class="desc">{subtitle}</p>
""")
+136 -64
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
@@ -52,7 +53,7 @@ def _get_user_or_redirect(request: Request):
if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
pass
logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user
@@ -88,7 +89,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception:
pass
logger.exception("_sidebar_data")
recent_pages = []
for repo in repos[:10]:
@@ -179,7 +180,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
@@ -193,7 +194,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
if row:
local_ws_id = row["id"]
except Exception:
pass
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
@@ -206,7 +207,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
pass
logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
@@ -252,10 +253,9 @@ 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="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else ""
@@ -283,10 +283,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = board_sidebar(request, owner, repo)
# Pass active workspace for breadcrumb nav menu
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
@@ -312,10 +311,9 @@ async 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"
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -437,8 +435,8 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
"""Account management panel."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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:
@@ -451,8 +449,8 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
@@ -489,6 +487,7 @@ async def help_page(request: Request):
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
@@ -590,6 +589,16 @@ FlowDeck supports three authentication methods:<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
@@ -645,8 +654,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -678,11 +687,20 @@ def _get_user_id(request: Request) -> int:
return user["id"] if user and user.get("id") else 1
def _require_user_id(request: Request) -> int:
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user["id"]
@router.put("/api/user/profile")
async def update_profile(request: Request):
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit()
@@ -691,13 +709,18 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
from app.password_utils import hash_password
from app.password_utils import hash_password, verify_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
uid = _get_user_id(request)
uid = _require_user_id(request)
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
current = body.get("current_password", "")
with get_conn() as conn:
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
if not row or not verify_password(current, row["password_hash"]):
raise HTTPException(403, "Current password is incorrect")
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit()
return {"status": "ok"}
@@ -706,7 +729,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
import secrets
uid = _get_user_id(request)
uid = _require_user_id(request)
token = secrets.token_hex(32)
with get_conn() as conn:
conn.execute(
@@ -719,7 +742,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
uid = _get_user_id(request)
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
@@ -744,14 +767,14 @@ async def dashboard(
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
except Exception:
pass
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
logger.exception("dashboard")
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
@@ -766,7 +789,7 @@ async def dashboard(
).fetchone()
has_gitea = bool(tok)
except Exception:
pass
logger.exception("dashboard")
if not has_gitea:
# Check if user has any workspace
@@ -779,7 +802,7 @@ async def dashboard(
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
logger.exception("dashboard")
return RedirectResponse("/local-workspace", status_code=302)
# ── Gitea user → full dashboard ──
@@ -796,8 +819,8 @@ async def dashboard(
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(request=request, repos=repos, search=search,
@@ -809,8 +832,8 @@ async def dashboard(
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -828,8 +851,8 @@ async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -890,9 +913,18 @@ async def list_workspace_projects(request: Request):
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {}
if rows:
for c in conn.execute(
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
",".join("?" * len(rows))
),
[r["id"] for r in rows],
).fetchall():
counts[c["parent_id"]] = c["c"]
for r in rows:
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
gitea_repos = []
# Use per-user token if available, otherwise return empty
@@ -911,7 +943,7 @@ async def list_workspace_projects(request: Request):
"forge": "gitea",
})
except Exception:
pass
logger.exception("list_workspace_projects")
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@@ -1005,8 +1037,8 @@ async def local_workspace_page(request: Request, folder: int = None):
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
"""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1127,9 +1159,21 @@ def _file_page_disk_path(page: dict):
return (full, filename, mime, size)
def _require_page_view(request: Request, page_id: int) -> None:
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
from app.services.permission_manager import PermissionManager
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
@router.get("/api/pages/{page_id}/download")
async def download_page_file(page_id: int):
async 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:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
@@ -1150,13 +1194,15 @@ async def download_page_file(page_id: int):
@router.get("/api/pages/{page_id}/file-content")
async def page_file_content(page_id: int):
async 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
only meaningful for plain-text / code / markdown files.
"""
from app.services.export import _file_text
_require_page_view(request, page_id)
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
@@ -1440,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), item_id),
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
)
conn.commit()
return {"status": "ok"}
@@ -1504,7 +1550,8 @@ async def upload_local_workspace_file(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1521,7 +1568,12 @@ async def upload_local_workspace_file(request: Request):
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1542,10 +1594,14 @@ async def upload_local_workspace_file(request: Request):
counter += 1
content = await f.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to("/data"))
rel_path = str(file_path.relative_to(data_root))
size = len(content)
mime = f.content_type or "application/octet-stream"
@@ -1577,7 +1633,8 @@ async def upload_local_workspace_folder(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1599,7 +1656,12 @@ async def upload_local_workspace_folder(request: Request):
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
import os
from app.middleware.security import validate_upload
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1658,9 +1720,13 @@ async def upload_local_workspace_folder(request: Request):
counter += 1
content = await matched.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
rel_path = str(file_path.relative_to("/data"))
rel_path = str(file_path.relative_to(data_root))
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
@@ -1710,8 +1776,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request):
"""Workspaces list page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [], include_workspace=False)
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1813,8 +1879,8 @@ async def select_workspace(request: Request, ws_id: int):
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
"""Settings & configuration page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1862,7 +1928,14 @@ async def serve_avatar_file(filename: str):
from pathlib import Path
from fastapi.responses import FileResponse
filepath = Path("/data/avatars") / filename
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
base_dir = Path("/data/avatars").resolve()
filepath = (base_dir / filename).resolve()
try:
filepath.relative_to(base_dir)
except ValueError:
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
return FileResponse(filepath)
@@ -2025,7 +2098,7 @@ async def add_item_tag(request: Request, item_id: int):
)
conn.commit()
except Exception:
pass
logger.exception("add_item_tag")
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
@@ -2131,9 +2204,8 @@ async def sidebar_workspace_tree(request: Request):
Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages
from app.templating import ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -2169,7 +2241,7 @@ async def sidebar_workspace_tree(request: Request):
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}"
@@ -2190,7 +2262,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required."""
from jinja2 import Environment, FileSystemLoader
from app.templating import ENV
with get_conn() as conn:
row = conn.execute(
@@ -2210,7 +2282,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
)
page = dict(row)
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
# Convert blocks to HTML for rendering
content_html = ""
+1 -1
View File
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
+15 -5
View File
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["export"], prefix="/api/export")
def _load_page_or_404(page_id: int) -> dict:
def _load_page_or_404(request: Request, page_id: int) -> dict:
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
doit pas délivrer le contenu d'une page énumérable par id."""
from app.auth.session import SessionManager
from app.services.permission_manager import PermissionManager
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(status_code=404, detail="Page not found")
return dict(row)
@@ -52,7 +62,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
headers = _download_header(filename, "text/markdown")
@@ -61,7 +71,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
headers = _download_header(filename, "text/html")
@@ -70,7 +80,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
except ImportError:
@@ -85,7 +95,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
page = _load_page_or_404(page_id)
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"
filename = f"{title}_site.zip"
+98
View File
@@ -0,0 +1,98 @@
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
from __future__ import annotations
import json
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import agent_policies as policies
from app.services.api_v2_helpers import audit_log
router = APIRouter(tags=["governance"])
def _owner_or_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
is_admin = bool(row and row["is_admin"])
if not is_admin and request.query_params.get("workspace_id"):
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(request.query_params.get("workspace_id"), user["id"])).fetchone()
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(request.query_params.get("workspace_id"),
user["id"])).fetchone()
if not member and not owner:
raise HTTPException(403, "Workspace access required")
if member and member["role"] not in ("admin", "editor", "owner"):
raise HTTPException(403, "Editor role required")
user["is_admin"] = is_admin
return user
@router.get("/api/v2/agent-policies")
async 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()
return {"policies": [dict(r) for r in rows]}
@router.post("/api/v2/agent-policies")
async def upsert_policy(request: Request):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
wid = body.get("workspace_id")
tools = body.get("allowed_tools")
if tools is not None and not isinstance(tools, list):
raise HTTPException(400, "allowed_tools must be a list or null")
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
require_approval)
VALUES (?,?,?,?)
ON CONFLICT(workspace_id) DO UPDATE SET
allowed_tools_json=excluded.allowed_tools_json,
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
(wid, json.dumps(tools) if tools is not None else None,
max(1, min(int(body.get("max_steps") or 12), 50)),
1 if body.get("require_approval") else 0))
conn.commit()
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
(wid,)).fetchone()
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
return JSONResponse(status_code=201, content=dict(row))
@router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
" LIMIT 100", (status,)).fetchall()
return {"approvals": [dict(r) for r in rows]}
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
async def decide_approval(approval_id: int, request: Request):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
if out is None:
raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request)
return out
+2 -2
View File
@@ -44,9 +44,9 @@ async def import_page(request: Request):
user = _current_user(request)
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
from jinja2 import Environment, FileSystemLoader
from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
return HTMLResponse(content=env.get_template("import.html").render(user=user))
+206
View File
@@ -0,0 +1,206 @@
"""FlowDeck — Calendar links + Meetings API (v7.1.0).
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
transcript, AI summary (fires ``meeting.summarized``).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import calendar_sync as cal
from app.services import meetings as meet
from app.services.api_v2_helpers import (
audit_log,
has_scope,
resolve_bearer_token,
row_to_dict,
)
router = APIRouter(tags=["calendar-meetings"])
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
# ── calendar links ─────────────────────────────────────────────────────────
@router.post("/api/v2/calendar-links")
async def create_link(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
provider = (body.get("provider") or "").lower()
if provider not in cal.PROVIDERS:
raise HTTPException(400, "provider must be google|caldav")
try:
collection_id = int(body.get("collection_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "collection_id required") from None
creds = body.get("credentials") or {}
if provider == "google" and not creds.get("access_token"):
raise HTTPException(400, "google needs credentials.access_token")
if provider == "caldav" and not creds.get("url"):
raise HTTPException(400, "caldav needs credentials.url")
try:
out = cal.save_link(user["id"], provider, collection_id, creds,
body.get("calendar_id") or "primary",
body.get("date_property") or "")
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
return JSONResponse(status_code=201, content=out)
@router.get("/api/v2/calendar-links")
async 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):
user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
return {"status": "deleted", "id": link_id}
@router.post("/api/v2/calendar-links/{link_id}/sync")
async def sync_now(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Link not found")
try:
stats = await cal.sync_link(link_id)
except (cal.SyncError, ValueError) as exc:
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
str(exc)) from None
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
return {"link_id": link_id, **stats}
# ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request):
_auth_user(request)
qp = request.query_params
try:
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
qp.get("date_property", ""))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
return out
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
try:
form = await request.form()
except Exception:
raise HTTPException(400, "multipart upload required") from None
upload = form.get("audio")
try:
page_id = int(form.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
language = (form.get("language") or "fr")[:10]
manual = (form.get("transcript") or "").strip()
if upload is None and not manual:
raise HTTPException(400, "audio file or transcript required")
audio_path = ""
if upload is not None:
filename = (upload.filename or "").lower()
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
if ext not in meet.AUDIO_EXTENSIONS:
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
data = await upload.read()
if len(data) > meet.MAX_AUDIO_BYTES:
raise HTTPException(413, "audio exceeds 100 MB")
if not data:
raise HTTPException(400, "empty audio file")
audio_path = str(meet.meetings_dir()
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
with open(audio_path, "wb") as fh:
fh.write(data)
transcript = manual
if not transcript and audio_path:
try:
transcript = meet.transcribe_audio(audio_path, language)
except meet.TranscriptionUnavailable as exc:
transcript = "" # stored; client transcribes or posts manual text later
_ = exc
try:
tid = meet.save_transcript(page_id, transcript, language, audio_path)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201, content={
**row_to_dict(row), "transcribed": bool(transcript)})
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
async def set_transcript_text(transcript_id: int, request: Request):
"""Store a client-side (manual) transcript on an existing row."""
_auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("transcript") or "").strip()
if not text:
raise HTTPException(400, "transcript required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
(text, transcript_id))
conn.commit()
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
return row_to_dict(row)
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
+2 -2
View File
@@ -6,10 +6,10 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
from app.db import get_conn
from app.templating import ENV
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
</div>"""
from app.routers.dashboard import _sidebar_data
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
+4 -6
View File
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
).fetchone()
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
)
conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
+2 -2
View File
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("welcome.html")
return HTMLResponse(content=template.render(
user=user,
+8 -16
View File
@@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token.
When an authenticated session is present the token is bound to that user
(revocable from Settings → API tokens); otherwise a legacy shared token is
created for backward compatibility.
"""
"""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", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
if user and user.get("id"):
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
else:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
+1 -1
View File
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
try:
await websocket.close(code=4401)
except Exception:
pass
logger.exception("ws_page")
return
conn = await manager.connect(websocket, page_id, user)
+297
View File
@@ -0,0 +1,297 @@
"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0).
``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and
deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and
revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification +
optional local-login enforcement per email domain.
See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import hashlib
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.api_v2_helpers import audit_log
router = APIRouter(tags=["scim"])
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
# ── auth ───────────────────────────────────────────────────────────────────
def _scim_guard(request: Request) -> dict:
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
with get_conn() as conn:
row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0",
(digest,)).fetchone()
if row:
return {"scim_token_id": row["id"], "name": row["name"]}
raise HTTPException(401, "SCIM token required")
def _admin_session(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(403, "Admin required")
return user
def _scim_user(row) -> dict:
d = dict(row)
return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"],
"name": {"formatted": d.get("full_name") or d["login"]},
"emails": [{"value": d.get("email") or "", "primary": True}],
"active": bool(d.get("is_active", 1)),
"meta": {"resourceType": "User"}}
# ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users")
async 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()
items = [_scim_user(r) for r in rows]
return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(items), "Resources": items}
@router.post("/scim/v2/Users")
async def scim_create(request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
username = (body.get("userName") or "").strip()
if not username:
raise HTTPException(400, "userName required")
email = ""
for em in body.get("emails") or []:
if isinstance(em, dict) and em.get("value"):
email = em["value"]
break
name = ((body.get("name") or {}).get("formatted") or username)[:200]
active = body.get("active", True)
with get_conn() as conn:
if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone():
raise HTTPException(409, "User already exists")
cur = conn.execute(
"INSERT INTO users (login, full_name, email, is_active, auth_method)"
" VALUES (?,?,?,?,'saml')",
(username, name, email, 1 if active else 0))
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
return JSONResponse(status_code=201, content=_scim_user(row))
@router.get("/scim/v2/Users/{user_id}")
async 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()
if not row:
raise HTTPException(404, "User not found")
return _scim_user(row)
def _apply_scim_update(conn, user_id: str, body: dict) -> None:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "User not found")
updates: dict = {}
if "userName" in body and body["userName"]:
updates["login"] = body["userName"].strip()
if isinstance(body.get("name"), dict) and body["name"].get("formatted"):
updates["full_name"] = body["name"]["formatted"][:200]
if isinstance(body.get("emails"), list):
for em in body["emails"]:
if isinstance(em, dict) and em.get("value"):
updates["email"] = em["value"][:200]
break
if "active" in body:
updates["is_active"] = 1 if body["active"] else 0
if updates:
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id))
if body.get("active") is False:
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
conn.commit()
@router.put("/scim/v2/Users/{user_id}")
async def scim_replace(user_id: str, request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
_apply_scim_update(conn, user_id, body)
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return _scim_user(row)
@router.patch("/scim/v2/Users/{user_id}")
async def scim_patch(user_id: str, request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
flat: dict = {}
for op in body.get("Operations") or []:
path = (op.get("path") or "").lower()
if path in ("username", "active"):
flat["userName" if path == "username" else "active"] = op.get("value")
with get_conn() as conn:
_apply_scim_update(conn, user_id, {**body, **flat})
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return _scim_user(row)
@router.delete("/scim/v2/Users/{user_id}")
async 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()
if not row:
raise HTTPException(404, "User not found")
# Deprovision = suspend (keeps content + audit trail).
conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,))
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
conn.commit()
return JSONResponse(status_code=204, content=None)
# ── SCIM token management (admin, session) ─────────────────────────────────
@router.post("/api/v2/scim/tokens")
async def create_scim_token(request: Request):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
raw = f"scim_{secrets.token_urlsafe(32)}"
digest = hashlib.sha256(raw.encode()).hexdigest()
with get_conn() as conn:
cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)"
" VALUES (?,?,?)",
(digest, str(body.get("name") or "SCIM")[:120], admin["id"]))
conn.commit()
audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request)
return JSONResponse(status_code=201,
content={"id": cur.lastrowid, "token": raw,
"warning": "shown once"})
@router.get("/api/v2/scim/tokens")
async 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"
" FROM scim_tokens ORDER BY id DESC").fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.delete("/api/v2/scim/tokens/{token_id}")
async 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,))
conn.commit()
audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request)
return {"status": "revoked", "id": token_id}
# ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims")
async def list_domains(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
out = []
for r in rows:
d = dict(r)
d.pop("txt_token", None)
out.append(d)
return {"domains": out}
@router.post("/api/v2/domain-claims")
async def create_domain(request: Request):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
domain = (body.get("domain") or "").strip().lower()
if not domain or "." not in domain or "/" in domain:
raise HTTPException(400, "valid domain required")
token = f"flowdeck-verify={secrets.token_hex(16)}"
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO domain_claims
(domain, txt_token, auto_join_role, enforce_sso, workspace_id)
VALUES (?,?,?,?,?)""",
(domain, token, body.get("auto_join_role") or "viewer",
1 if body.get("enforce_sso") else 0, body.get("workspace_id")))
conn.commit()
except Exception:
raise HTTPException(409, "Domain already claimed") from None
did = cur.lastrowid
audit_log(admin, "domain.claim", "domain", did, domain, request)
return JSONResponse(status_code=201, content={
"id": did, "domain": domain,
"verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt",
"expected_content": token})
@router.post("/api/v2/domain-claims/{domain_id}/verify")
async def verify_domain(domain_id: int, request: Request):
admin = _admin_session(request)
import httpx
with get_conn() as conn:
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
if not row:
raise HTTPException(404, "Domain not found")
claim = dict(row)
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url)
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
except Exception: # noqa: BLE001 — unreachable domain = not verified
ok = False
if ok:
with get_conn() as conn:
conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,))
conn.commit()
audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request)
return {"id": domain_id, "verified": ok}
@router.delete("/api/v2/domain-claims/{domain_id}")
async 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,))
conn.commit()
audit_log(admin, "domain.delete", "domain", domain_id, "", request)
return {"status": "deleted", "id": domain_id}
+95
View File
@@ -0,0 +1,95 @@
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
(LLM when configured, extractive offline fallback), cached 10 min.
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
See ``docs/V69_Search_Ask_AI.md``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import semantic_search as sem
from app.services.api_v2_helpers import (
audit_log,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
)
router = APIRouter(tags=["search-ai"])
def _auth_user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if not has_scope(user.get("_token_scopes") or "read", "read"):
raise HTTPException(403, "Insufficient scope. Required: read")
return user
raise HTTPException(401, "Authentication required")
@router.get("/api/v2/search/hybrid")
async 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:
raise HTTPException(400, "q is required")
limit, offset = parse_pagination(request)
ws_raw = request.query_params.get("workspace_id")
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
workspace_id=workspace_id)
page = results[offset:offset + limit]
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
# 5 min). Best-effort is handled by tests calling index_resource directly.
resp = JSONResponse({"query": q, "results": page,
"total": len(results), "limit": limit, "offset": offset})
for k, v in paginate_headers(len(results)).items():
resp.headers[k] = v
return resp
@router.post("/api/v2/search/ask")
async def ask_ai(request: Request):
user = _auth_user(request)
try:
body = await request.json()
except Exception:
body = {}
question = (body.get("question") or body.get("q") or "").strip()
if not question:
raise HTTPException(400, "question is required")
ws = body.get("workspace_id")
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
out = await sem.ask(question, user, workspace_id)
audit_log(user, "search.ask", "search", "", question[:200], request)
return {"question": question, "workspace_id": workspace_id, **out}
@router.get("/api/v2/search/index-status")
async def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request)
with get_conn() as conn:
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
pages_total = conn.execute(
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
return {"indexed_resources": indexed, "vectors": vectors,
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
"user_id": user.get("id")}
+1 -1
View File
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
try:
request.session.clear()
except Exception:
pass
logger.exception("revoke_session")
return {"status": "revoked"}
+6 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
except Exception:
pass
logger.exception("share_page")
return {
"id": share_id,
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page")
return {
"page_id": page_id,
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page")
return {
"page_id": page_id,
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type,
accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
conn.commit()
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
"status": "tracked",
"user_id": user["id"],
"page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(),
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
+4 -3
View File
@@ -27,9 +27,10 @@ DEFAULT_CONFIG = {
"recents": {"visible": True, "order": 3, "show_count": 10},
"favorites": {"visible": True, "order": 4, "show_count": 10},
"agents": {"visible": True, "order": 5, "show_count": None},
"shared": {"visible": True, "order": 6, "show_count": 10},
"published": {"visible": True, "order": 7, "show_count": 10},
"private": {"visible": True, "order": 8, "show_count": None},
"teamspaces": {"visible": True, "order": 6, "show_count": None},
"shared": {"visible": True, "order": 7, "show_count": 10},
"published": {"visible": True, "order": 8, "show_count": 10},
"private": {"visible": True, "order": 9, "show_count": None},
}
+838
View File
@@ -0,0 +1,838 @@
"""FlowDeck — Sites & public Forms (v6.8.0).
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
password/expiry gating, SEO + view stats, and anonymous collection forms
(/f/<token>) with rate limiting, validation and notifications.
Auth: session cookie first, Bearer token fallback (api_tokens,
extension_devices, legacy user_tokens) via api_v2_helpers.
"""
from __future__ import annotations
import hashlib
import html
import json
import logging
import re
import secrets
import time
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.password_utils import hash_password, verify_password
from app.services.api_v2_helpers import (
audit_log,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
row_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sites"])
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$")
# In-memory rate limiting for anonymous form posts: ip -> (window_start, count).
_form_rate: dict[str, tuple[float, int]] = {}
_FORM_RATE_MAX = 20
_FORM_RATE_WINDOW = 3600.0
# ── helpers ────────────────────────────────────────────────────────────────
def _slugify(title: str) -> str:
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 or "untitled"
def _check_slug(slug: str) -> None:
if not _SLUG_RE.match(slug or ""):
raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.")
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
"""Session-first auth, Bearer fallback. Enforces scope for Bearer tokens."""
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
def _site_auth_cookie(site_id: int) -> str:
return f"site_auth_{site_id}"
def _site_unlocked(request: Request, site: dict) -> bool:
if not site.get("password_hash"):
return True
from itsdangerous import BadSignature, URLSafeTimedSerializer
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
try:
val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400)
return val == site["id"]
except BadSignature:
return False
except Exception:
return False
def _site_expired(site: dict) -> bool:
exp = site.get("expires_at")
if not exp:
return False
try:
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp() < time.time()
except Exception:
return False
def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None:
row = None
if slug:
row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone()
elif host:
row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone()
return dict(row) if row else None
def _site_pages(conn, site_id: int) -> list[dict]:
rows = conn.execute(
"""SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position
FROM site_pages sp JOIN pages p ON p.id = sp.page_id
WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='')
ORDER BY sp.position, p.id""",
(site_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}"
out.append(d)
return out
def _find_site_page(pages: list[dict], ref: str) -> dict | None:
ref = (ref or "").strip()
if ref.isdigit():
for p in pages:
if p["id"] == int(ref):
return p
for p in pages:
if p["slug"] == ref:
return p
# slug with -<id> suffix fallback
m = re.search(r"-(\d+)$", ref)
if m:
for p in pages:
if p["id"] == int(m.group(1)):
return p
return None
def _render_page_html(page: dict) -> str:
"""Render a pages row to HTML (blocks → public renderer, else <pre>)."""
if page.get("content_format") == "blocks" and page.get("content"):
try:
from app.routers import dashboard as _dash
blocks = json.loads(page["content"])
try:
from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks)
except Exception:
logger.exception("_render_page_html")
titles: dict = {}
try:
from app.db import get_conn as _gc
from app.services.wiki_links import token_labels
with _gc() as _c:
titles = token_labels(_c, page["content"])
except Exception:
titles = {}
return _dash._render_blocks_public(blocks, titles)
except Exception:
return f"<p>{html.escape(str(page.get('content', '')))}</p>"
if page.get("content"):
return (
"<pre style='white-space:pre-wrap;font-family:system-ui;"
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
)
return "<p style='color:#999'>Empty page.</p>"
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
body_html: str, noindex: bool = False) -> str:
nav = "".join(
f"<a href='/s/{site['slug']}/{p['slug']}'"
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
for p in pages
)
robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow"
desc = html.escape((site.get("title") or title)[:160])
theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff"
theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222"
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="robots" content="{robots}">
<meta name="description" content="{desc}">
<meta property="og:title" content="{html.escape(title)}">
<meta property="og:description" content="{desc}">
<meta name="twitter:card" content="summary">
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
<body><div class="layout"><nav class="nav">
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
<main class="main">{body_html}</main></div></body></html>"""
def _track_view(site_id: int) -> None:
day = datetime.now(UTC).strftime("%Y-%m-%d")
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1)
ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""",
(site_id, day),
)
conn.commit()
except Exception:
logger.exception("_track_view")
def _form_config(conn, collection_id: int) -> dict:
row = conn.execute(
"SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
try:
cfg = json.loads(row["form_config_json"] or "{}")
except Exception:
cfg = {}
return {"id": row["id"], "name": row["name"], "config": cfg}
def _check_form_rate(ip: str) -> None:
now = time.time()
start, count = _form_rate.get(ip, (now, 0))
if now - start > _FORM_RATE_WINDOW:
_form_rate[ip] = (now, 1)
return
if count >= _FORM_RATE_MAX:
raise HTTPException(429, "Too many submissions. Try again later.")
_form_rate[ip] = (start, count + 1)
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
@router.post("/api/v2/sites")
async def create_site(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
root_page_id = body.get("root_page_id")
if not root_page_id:
raise HTTPException(400, "root_page_id is required")
slug = (body.get("slug") or "").strip().lower() or None
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
if not page:
raise HTTPException(404, "Root page not found")
if not slug:
slug = _slugify(page["title"])
base, i = slug, 1
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
slug = f"{base}-{i}"
i += 1
else:
_check_slug(slug)
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
raise HTTPException(409, "Slug already taken")
theme = body.get("theme", "dark")
if theme not in ("light", "dark"):
raise HTTPException(400, "theme must be light or dark")
custom_domain = (body.get("custom_domain") or "").strip() or None
if custom_domain and conn.execute(
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
).fetchone():
raise HTTPException(409, "Domain already linked to another site")
expires_at = body.get("expires_at")
if expires_at:
try:
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except Exception:
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
cur = conn.execute(
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
expires_at, noindex, analytics_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(slug, root_page_id, body.get("title") or page["title"],
theme, custom_domain, expires_at,
1 if body.get("noindex") else 0,
(body.get("analytics_id") or "")[:120], user["id"]),
)
site_id = cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
(site_id, root_page_id),
)
conn.commit()
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
return JSONResponse(status_code=201, content=row_to_dict(site))
@router.get("/api/v2/sites")
async def list_sites(request: Request):
user = _auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
).fetchone()[0]
rows = conn.execute(
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
(user["id"], limit, offset),
).fetchall()
resp = JSONResponse([row_to_dict(r) for r in rows])
for k, v in paginate_headers(total).items():
resp.headers[k] = v
return resp
@router.get("/api/v2/sites/{site_id}")
async 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()
if not row:
raise HTTPException(404, "Site not found")
site = dict(row)
if site.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
pages = _site_pages(conn, site_id)
out = row_to_dict(row)
out["pages"] = pages
return out
@router.patch("/api/v2/sites/{site_id}")
async def update_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row:
raise HTTPException(404, "Site not found")
site = dict(row)
if site.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
updates: dict = {}
if "title" in body:
updates["title"] = str(body["title"] or "")[:200]
if "theme" in body:
if body["theme"] not in ("light", "dark"):
raise HTTPException(400, "theme must be light or dark")
updates["theme"] = body["theme"]
if "slug" in body and body["slug"] != site["slug"]:
_check_slug(str(body["slug"]).lower())
if conn.execute(
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
).fetchone():
raise HTTPException(409, "Slug already taken")
updates["slug"] = str(body["slug"]).lower()
if "custom_domain" in body:
dom = (body["custom_domain"] or "").strip() or None
if dom and conn.execute(
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
).fetchone():
raise HTTPException(409, "Domain already linked to another site")
updates["custom_domain"] = dom
if "expires_at" in body:
if body["expires_at"]:
try:
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
except Exception:
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
updates["expires_at"] = body["expires_at"]
if "noindex" in body:
updates["noindex"] = 1 if body["noindex"] else 0
if "analytics_id" in body:
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
if "password" in body:
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
if updates:
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
conn.commit()
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
return row_to_dict(site)
@router.delete("/api/v2/sites/{site_id}")
async 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()
if not row:
raise HTTPException(404, "Site not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
conn.commit()
audit_log(user, "site.delete", "site", site_id, "", request)
return {"status": "deleted", "id": site_id}
@router.get("/api/v2/sites/{site_id}/pages")
async 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()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
@router.post("/api/v2/sites/{site_id}/pages")
async def add_site_page(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
).fetchone()[0]
conn.execute(
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
(site_id, page_id, pos),
)
conn.commit()
pages = _site_pages(conn, site_id)
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
return {"site_id": site_id, "pages": pages}
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async 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()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
if page_id == row["root_page_id"]:
raise HTTPException(400, "Cannot remove the root page")
conn.execute(
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
)
conn.commit()
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
return {"status": "removed", "site_id": site_id, "page_id": page_id}
@router.get("/api/v2/sites/{site_id}/stats")
async 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:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
rows = conn.execute(
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
(site_id, days),
).fetchall()
total = conn.execute(
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
).fetchone()[0]
return {"site_id": site_id, "total_views": total,
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
# ── Public site rendering ──────────────────────────────────────────────────
def _public_guard(site: dict, request: Request):
if _site_expired(site):
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
if site.get("password_hash") and not _site_unlocked(request, site):
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<form method="post" action="/s/{site['slug']}/auth">
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
<input type="password" name="password" placeholder="Password"
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
status_code=401,
)
return None
@router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", ""))
if not site:
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
guard = _public_guard(site, request)
if guard:
return guard
pages = _site_pages(conn, site["id"])
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
if not page:
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
return _site_shell(site=site, pages=pages, current_id=page["id"],
title=page.get("title") or "Untitled", body_html=body)
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async 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"):
return PlainTextResponse("Not found", status_code=404)
pages = _site_pages(conn, site["id"])
base = str(request.base_url).rstrip("/")
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
]
return PlainTextResponse(
"<?xml version='1.0' encoding='UTF-8'?>"
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
f"{''.join(urls)}</urlset>",
media_type="application/xml",
)
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async 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:
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
guard = _public_guard(site, request)
if guard:
return guard
pages = _site_pages(conn, site["id"])
target = _find_site_page(pages, page_ref)
if not target:
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
if not page:
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
return _site_shell(site=site, pages=pages, current_id=page["id"],
title=page.get("title") or "Untitled", body_html=body)
@router.post("/s/{slug}/auth")
async def public_site_auth(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
if not site:
return JSONResponse({"detail": "Site not found"}, status_code=404)
if not site.get("password_hash"):
return {"status": "public"}
ctype = request.headers.get("content-type", "")
password = ""
if "application/json" in ctype:
try:
password = (await request.json()).get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
else:
try:
form = await request.form()
password = form.get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
if not verify_password(password or "", site["password_hash"] or ""):
raise HTTPException(401, "Wrong password")
from itsdangerous import URLSafeTimedSerializer
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
resp = JSONResponse({"status": "unlocked"})
resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]),
httponly=True, samesite="lax", max_age=86400, path="/")
return resp
# ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request):
_auth_user(request)
with get_conn() as conn:
info = _form_config(conn, collection_id)
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
@router.put("/api/v2/collections/{collection_id}/form")
async def put_form_config(collection_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
info = _form_config(conn, collection_id)
cfg = info["config"] if isinstance(info["config"], dict) else {}
if "enabled" in body:
cfg["enabled"] = bool(body["enabled"])
for key in ("title", "success_message"):
if key in body:
cfg[key] = str(body[key] or "")[:300]
for key in ("fields", "required", "notify_user_ids"):
if key in body and isinstance(body[key], list):
cfg[key] = body[key][:50]
if "public_token" in body and body["public_token"]:
tok = str(body["public_token"])
if not _FORM_TOKEN_RE.match(tok):
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
cfg["public_token"] = tok
if cfg.get("enabled") and not cfg.get("public_token"):
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
conn.execute(
"UPDATE collections SET form_config_json=? WHERE id=?",
(json.dumps(cfg), collection_id),
)
conn.commit()
audit_log(user, "form.config", "collection", collection_id, "", request)
return {"collection_id": collection_id, "form": cfg}
def _collection_props(conn, collection_id: int) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
@router.get("/f/{token}", response_class=HTMLResponse)
async 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()
target = None
if _FORM_TOKEN_RE.match(token or ""):
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
try:
cfg = json.loads(c["form_config_json"] or "{}")
except Exception:
continue
if cfg.get("enabled") and cfg.get("public_token") == token:
target = (c, cfg)
break
_ = row
if not target:
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
coll, cfg = target
props = _collection_props(conn, coll["id"])
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
required = set(cfg.get("required") or [])
inputs = ""
for name in fields:
prop = next((p for p in props if p["name"] == name), None)
ptype = (prop or {}).get("prop_type", "text")
itype = {"number": "number", "email": "email", "url": "url",
"date": "date", "phone": "tel"}.get(ptype, "text")
req = "required" if name in required else ""
if ptype in ("select", "status") and prop:
try:
opts = json.loads(prop.get("options_json") or "[]")
except Exception:
opts = []
opts_html = "".join(
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
elif ptype == "checkbox":
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
f"{html.escape(name)}</label>")
else:
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
return HTMLResponse(
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
</head><body>{chrome}
<form method="post" action="/f/{token}">
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
{inputs}<button>Submit</button></form></body></html>"""
)
@router.post("/f/{token}")
async def submit_form(request: Request, token: str):
ip = request.client.host if request.client else "unknown"
_check_form_rate(ip or "unknown")
ctype = request.headers.get("content-type", "")
data: dict = {}
if "application/json" in ctype:
try:
data = await request.json()
except Exception:
data = {}
else:
try:
form = await request.form()
data = dict(form)
except Exception:
data = {}
if data.get("__hp"):
raise HTTPException(400, "Spam detected")
with get_conn() as conn:
target = None
if _FORM_TOKEN_RE.match(token or ""):
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
try:
cfg = json.loads(c["form_config_json"] or "{}")
except Exception:
continue
if cfg.get("enabled") and cfg.get("public_token") == token:
target = (c, cfg)
break
if not target:
# NOTE: return (not raise) — the global 404 handler redirects
# non-/api paths to /workspaces, which would turn this into a 200.
return JSONResponse({"detail": "Form not found"}, status_code=404)
coll, cfg = target
props = _collection_props(conn, coll["id"])
by_name = {p["name"]: p for p in props}
fields = cfg.get("fields") or list(by_name)[:10]
required = set(cfg.get("required") or [])
values: dict = {}
for name in fields:
prop = by_name.get(name)
if not prop:
continue
raw = data.get(name, "")
if prop["prop_type"] == "checkbox":
raw = True if raw in (True, "on", "true", "1", "checked") else False
if name in required and (raw is None or raw == "" or raw is False):
raise HTTPException(400, f"Field required: {name}")
values[str(prop["id"])] = raw
# Validate via property_types.validate_property_rule
try:
from app.services.property_types import validate_property_rule
for name in fields:
prop = by_name.get(name)
if not prop:
continue
ok, _msg = validate_property_rule(
prop.get("prop_type", "text"), values.get(str(prop["id"])),
prop.get("validation_json") or prop.get("options_json") or "")
if not ok:
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
except HTTPException:
raise
except Exception:
logger.exception("submit_form")
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
cur = conn.execute(
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
VALUES (?, ?, ?)""",
(coll["id"], title or "Form response", json.dumps(values)),
)
row_id = cur.lastrowid
ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest()
conn.execute(
"INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)",
(coll["id"], row_id, ip_hash),
)
conn.commit()
notify_ids = cfg.get("notify_user_ids") or []
# Notify (never throws the submission)
try:
from app.services.notifications import create_notification
for uid in notify_ids[:20]:
try:
create_notification(int(uid), None, "form_response",
f"New response: {coll['name']}",
f"{title}", "collection", coll["id"],
f"/db/{coll['id']}")
except Exception:
continue
except Exception:
logger.exception("submit_form")
try:
from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception:
logger.exception("submit_form")
if "application/json" in ctype:
return {"status": "ok", "row_id": row_id,
"message": cfg.get("success_message") or "Merci !"}
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
)
# used by tests to reset the anonymous rate limiter
def _reset_form_rate() -> None:
_form_rate.clear()
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
__all__ = ["router", "get_bearer_user"]
+656
View File
@@ -0,0 +1,656 @@
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
Two families of routes:
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
ACS callback, SP metadata, Single Logout). The callback endpoints are
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
single-use ``sso_requests`` relay token + full assertion validation.
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
with write scope), consumed by Settings → Admin → SSO / Enterprise.
Every attempt — success or rejection — lands in ``sso_login_history``.
"""
from __future__ import annotations
import logging
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.providers import oidc_provider, saml_provider
from app.auth.session import SessionManager
from app.services import sso_provisioning as sso
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sso"])
DEFAULT_NEXT = "/workspaces"
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
_RATE_WINDOW = 60.0
_RATE_MAX = 5
_rate_store: dict[str, tuple[float, int]] = {}
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
from app.config import settings
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
key = f"{bucket}:{ip}"
now = time.time()
window, count = _rate_store.get(key, (0.0, 0))
if now - window > _RATE_WINDOW:
_rate_store[key] = (now, 1)
return True
if count >= _RATE_MAX:
return False
_rate_store[key] = (window, count + 1)
return True
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
"""Small standalone error/info page (same styling as the login page)."""
return HTMLResponse(
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>FlowDeck — {title}</title><style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
status_code=status,
)
def _sso_config_or_error() -> dict | None:
cfg = sso.get_sso_config()
return sso.normalize_config(cfg) if cfg else None
def _session_cookie(user_data: dict, request: Request):
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
return SessionManager.create_session(user_data, request)
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
provider_type = (cfg or {}).get("provider_type", "saml")
sso.log_sso_login(
user_id=None,
provider_type=provider_type,
provider_name=(cfg or {}).get("name") or "SSO",
identifier=identifier,
request=request,
success=False,
error=message,
)
logger.warning("SSO login rejected: %s", message)
safe = (
message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")[:400]
)
return _page(
"SSO sign-in failed",
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
status=403,
)
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
@router.get("/auth/saml/login")
async 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)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page(
"SAML not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
cfg = sso.ensure_sp_keypair(cfg)
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
csrf_token = secrets.token_hex(16)
# The id is only known after building the request, so build it first with a
# placeholder relay state, then re-issue with the real one? python3-saml
# builds the AuthnRequest inside login(); we instead create the row right
# after login() returns the URL — but the RelayState is already embedded.
# So: generate the request id ourselves is not possible → build the URL,
# then patch the RelayState by rebuilding with the known id.
from urllib.parse import parse_qs, urlencode, urlparse
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
authn_id = provisional[1]
relay = f"{authn_id}.{csrf_token}"
sso.create_request(
"saml_authn",
request_id=authn_id,
relay_state=csrf_token,
next_path=sso.safe_next_path(next),
)
# Replace the placeholder RelayState with the real token (same SAMLRequest).
parsed = urlparse(provisional[0])
params = parse_qs(parsed.query)
params["RelayState"] = [relay]
flat = [(k, v) for k, values in params.items() for v in values]
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
return RedirectResponse(url, status_code=302)
@router.post("/auth/saml/callback")
async def saml_callback(request: Request):
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
if not _rate_ok(request, "saml-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
form = await request.form()
saml_response = str(form.get("SAMLResponse") or "")
relay_state = str(form.get("RelayState") or "")
if not saml_response:
return _login_error("Missing SAMLResponse", cfg=None, request=request)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _login_error("SAML is not configured", cfg=None, request=request)
authn_id, _, csrf_token = relay_state.partition(".")
pending = sso.peek_request("saml_authn", authn_id)
if not pending and sso.was_consumed("saml_authn", authn_id):
# Same assertion twice: the single-use row is already spent.
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
if not pending or not csrf_token or not secrets.compare_digest(
pending.get("relay_state", ""), csrf_token
):
return _login_error(
"Unknown or expired login request (start again from the login page)",
cfg=cfg, request=request,
)
try:
identity = saml_provider.process_response(
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
)
except saml_provider.SAMLError as err:
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
# Single-use: the same AuthnRequest id can never authenticate twice.
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
if not consumed:
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
claims = sso.identity_from_saml(identity, cfg)
identifier = sso.sso_identifier_field(claims)
try:
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="saml",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
user_data = dict(user)
user_data["_sso_name_id"] = identity.name_id
user_data["_sso_session_index"] = identity.session_index
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(user_data, request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/saml/metadata")
async 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":
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
cfg = sso.ensure_sp_keypair(cfg)
try:
xml = saml_provider.metadata_xml(request, cfg)
except saml_provider.SAMLError as err:
return _page("Metadata error", f"<p>{err}</p>", status=500)
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
local session);
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
of our own SLO, or a LogoutRequest issued by the IdP).
"""
form = dict(await request.form()) if request.method == "POST" else {}
query = dict(request.query_params)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
response = RedirectResponse(next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
if payload:
try:
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
except saml_provider.SAMLError as err:
logger.warning("SLO processing failed: %s", err)
return _login_error(str(err), cfg=cfg, request=request)
if errors:
return _login_error(
"; ".join(errors)[:300], cfg=cfg, request=request
)
response = RedirectResponse(url or next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
# SP-initiated
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
response = RedirectResponse(next, status_code=302)
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
try:
logout_url = saml_provider.build_logout_url(
request, cfg,
return_to=sso.safe_next_path(next),
name_id=user.get("_sso_name_id", ""),
session_index=user.get("_sso_session_index", ""),
)
# Keep the cookie-clearing headers built above: hand the browser
# to the IdP with our local session already dead.
response = RedirectResponse(logout_url, status_code=302)
response.delete_cookie("flowdeck_session")
return response
except saml_provider.SAMLError as err:
logger.warning("SP-initiated SLO failed: %s", err)
return response
@router.get("/auth/saml/logout")
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
return await _saml_logout(request, next)
@router.post("/auth/saml/logout")
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
return await _saml_logout(request, next)
# ═════════════════════════════════ OIDC ═══════════════════════════════════
@router.get("/auth/oidc/login")
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
"""Redirect to the OIDC provider (authorization code + PKCE)."""
if not _rate_ok(request, "oidc"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _page(
"OIDC not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, request=request)
from app.auth.providers.saml_provider import external_base_url
state = secrets.token_hex(32)
nonce = secrets.token_hex(16)
verifier, challenge = oidc_provider.pkce_pair()
sso.create_request(
"oidc",
request_id=state,
relay_state=nonce,
code_verifier=verifier,
next_path=sso.safe_next_path(next),
)
url = oidc_provider.build_authorize_url(
doc,
client_id=cfg["client_id"],
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
scope=cfg.get("scope") or "openid profile email",
state=state,
nonce=nonce,
code_challenge=challenge,
)
return RedirectResponse(url, status_code=302)
async def _oidc_callback(request: Request):
"""OIDC callback: exchange the code, validate the ID token, open a session."""
if not _rate_ok(request, "oidc-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
params = dict(request.query_params)
if request.method == "POST":
params.update({k: str(v) for k, v in (await request.form()).items()})
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _login_error("OIDC is not configured", cfg=None, request=request)
if params.get("error"):
return _login_error(
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
cfg=cfg, request=request,
)
code, state = params.get("code", ""), params.get("state", "")
pending = sso.consume_request("oidc", state)
if not code or not pending:
return _login_error(
"Unknown or expired OIDC state (start again from the login page)",
cfg=cfg, request=request,
)
from app.auth.providers.saml_provider import external_base_url
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
tokens = await oidc_provider.exchange_code(
doc,
client_id=cfg["client_id"],
client_secret=sso.client_secret_value(cfg),
code=code,
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
code_verifier=pending.get("code_verifier", ""),
)
jwks = await _fetch_jwks(doc)
claims = oidc_provider.validate_id_token(
tokens.get("id_token", ""),
issuer=cfg["issuer_url"],
client_id=cfg["client_id"],
nonce=pending.get("relay_state", ""),
jwks=jwks,
)
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
merged = {**claims, **userinfo}
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
identifier = sso.sso_identifier_field(identity)
try:
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="oidc",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(dict(user), request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/oidc/callback")
async def oidc_callback(request: Request):
"""OIDC callback (GET, authorization code in the query string)."""
return await _oidc_callback(request)
@router.post("/auth/oidc/callback")
async def oidc_callback_post(request: Request):
"""OIDC callback (POST, form_post response mode)."""
return await _oidc_callback(request)
async def _fetch_jwks(doc: dict) -> dict:
url = doc.get("jwks_uri")
if not url:
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
import httpx
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
except Exception as err:
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
if not isinstance(data, dict) or not data.get("keys"):
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
return data
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Local logout + RP-initiated logout at the provider when supported."""
cfg = _sso_config_or_error()
response = RedirectResponse(next, status_code=302)
cookie = request.cookies.get("flowdeck_session", "")
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if cfg and cfg["provider_type"] == "oidc":
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
end_session = doc.get("end_session_endpoint")
if end_session:
from urllib.parse import urlencode
from app.auth.providers.saml_provider import external_base_url
qs = urlencode({
"client_id": cfg["client_id"],
"post_logout_redirect_uri": external_base_url(request) + next,
})
sep = "&" if "?" in end_session else "?"
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
except oidc_provider.OIDCError as err:
logger.debug("RP-initiated logout skipped: %s", err)
return response
@router.get("/auth/oidc/logout")
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
return await _oidc_logout(request, next)
@router.post("/auth/oidc/logout")
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (POST)."""
return await _oidc_logout(request, next)
# ═══════════════════════ Admin configuration API ══════════════════════════
async def _require_admin(request: Request, *, write: bool) -> dict:
"""Admin identity: Bearer token (scope read/write) or an admin session.
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
exempted in the middleware, so the check lives here for this router.
"""
auth_header = request.headers.get("authorization") or ""
if auth_header.lower().startswith("bearer "):
user = resolve_bearer_token(auth_header[7:].strip())
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
scopes = user.get("_token_scopes") or ""
need = "write" if write else "read"
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
return user
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
with get_conn() as conn:
row = conn.execute(
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
(user["id"],),
).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Admin access required")
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
cookie = request.cookies.get("csrf_token", "")
header = request.headers.get("X-CSRF-Token", "")
if not cookie or not header or not secrets.compare_digest(cookie, header):
raise HTTPException(status_code=403, detail="CSRF validation failed")
return dict(row)
@router.get("/api/v2/sso/providers")
async 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:
return {"providers": [], "sso_only": False}
from app.auth.providers.saml_provider import external_base_url
base = external_base_url(request)
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
return {
"providers": [{
"type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"icon": "🏢",
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
}],
"sso_only": bool(cfg.get("sso_only")),
"base_url": base,
}
@router.get("/api/v2/sso/config")
async def get_sso_config_api(request: Request):
"""Read the current SSO configuration (secrets never returned)."""
await _require_admin(request, write=False)
cfg = _sso_config_or_error()
return sso.public_config_view(cfg)
@router.post("/api/v2/sso/config")
@router.put("/api/v2/sso/config")
async def save_sso_config_api(request: Request):
"""Create/replace the SSO configuration (admin, scope write)."""
admin = await _require_admin(request, write=True)
try:
payload = await request.json()
except Exception as err:
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
try:
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
except sso.SSOConfigError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
f"provider={saved.get('provider_type')}", request)
return sso.public_config_view(saved)
@router.delete("/api/v2/sso/config")
async def delete_sso_config_api(request: Request):
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
admin = await _require_admin(request, write=True)
removed = sso.delete_sso_config()
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
return {"status": "ok", "disabled": removed}
@router.get("/api/v2/sso/workspaces")
async def sso_workspaces(request: Request):
"""Workspaces available for default assignment / group mapping."""
await _require_admin(request, write=False)
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
).fetchall()
cfg = _sso_config_or_error()
return {
"workspaces": [dict(r) for r in rows],
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
"sso_only": bool((cfg or {}).get("sso_only")),
"provisioned_users": sso.provisioned_count(),
}
@router.post("/api/v2/sso/sync")
async def sso_sync(request: Request):
"""Re-apply group → workspace role mapping for every SSO user."""
admin = await _require_admin(request, write=True)
try:
result = sso.force_sync_all_groups()
except sso.SSOProvisioningError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
return {"status": "ok", **result}
@router.get("/api/v2/sso/history")
async def sso_history(request: Request, limit: int = 50):
"""Audit trail of SSO login attempts (successes and rejections)."""
await _require_admin(request, write=False)
from app.db import get_conn
limit = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
h.sso_identifier, h.ip_address, h.success, h.error_message,
h.created_at
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
ORDER BY h.id DESC LIMIT ?""",
(limit,),
).fetchall()
out = []
for r in rows:
d = dict(r)
ident = d.get("sso_identifier") or ""
if "|" in ident: # drop the stored group list from the UI payload
d["sso_identifier"] = ident.split("|", 1)[0]
d["success"] = bool(d["success"])
out.append(d)
return {"history": out}
+5 -6
View File
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
if isinstance(_imgs, list) and _imgs:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception:
pass
logger.exception("clip_page")
clip_data = {
"url": url,
"title": title[:200],
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception:
pass
logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
@router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request):
from jinja2 import Environment, FileSystemLoader
from app.routers.dashboard import _sidebar_data
from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
try:
sidebar = _sidebar_data(request, [])
except Exception:
@@ -250,7 +249,7 @@ async def extensions_page(request: Request):
devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices)
except Exception:
pass
logger.exception("extensions_page")
content_html = f"""
<style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
+226
View File
@@ -0,0 +1,226 @@
"""FlowDeck — Passkeys / WebAuthn (v7.2.0).
Registration + passwordless login via the ``webauthn`` package (pinned in
requirements). Challenges live in a short-lived in-memory store (5 min,
single-process — same tradeoff as the SSE rooms). RP ID is derived from the
request host. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
router = APIRouter(tags=["webauthn"], prefix="/auth/webauthn")
# key -> (challenge bytes, expires_at). key = f"reg:{user_id}" | f"login:{login}".
_challenges: dict[str, tuple[bytes, float]] = {}
_CHALLENGE_TTL = 300.0
def _require_lib():
try:
import webauthn # noqa: F401
return True
except ImportError:
return False
def _store_challenge(key: str, challenge: bytes) -> None:
_challenges[key] = (challenge, time.time() + _CHALLENGE_TTL)
def _take_challenge(key: str) -> bytes | None:
item = _challenges.pop(key, None)
if not item:
return None
challenge, exp = item
return challenge if exp > time.time() else None
def _rp(request: Request) -> tuple[str, str]:
host = (request.url.hostname or "localhost").split(":")[0]
return host, f"{request.url.scheme}://{request.headers.get('host', host)}"
def _session_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
@router.post("/register/begin")
async 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
user = _session_user(request)
rp_id, _origin = _rp(request)
with get_conn() as conn:
existing = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
exclude = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
for r in existing]
options = generate_registration_options(
rp_id=rp_id, rp_name="FlowDeck", user_name=user.get("login", f"user{user['id']}"),
user_id=str(user["id"]).encode(), exclude_credentials=exclude or None)
_store_challenge(f"reg:{user['id']}", options.challenge)
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
@router.post("/register/finish")
async def register_finish(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_registration_response
user = _session_user(request)
try:
body = await request.json()
except Exception:
body = {}
challenge = _take_challenge(f"reg:{user['id']}")
if not challenge:
raise HTTPException(400, "Challenge expired — begin again")
rp_id, origin = _rp(request)
try:
verified = verify_registration_response(
credential=body.get("credential") or {},
expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin,
require_user_verification=False)
except Exception as exc: # noqa: BLE001 — invalid attestation → 400, never 500
raise HTTPException(400, f"Registration rejected: {exc}") from None
import base64
cred_id = base64.urlsafe_b64encode(verified.credential_id).decode().rstrip("=")
pubkey = base64.b64encode(bytes(verified.credential_public_key)).decode()
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, sign_count, name)
VALUES (?,?,?,?,?)""",
(user["id"], cred_id, pubkey, verified.sign_count,
str(body.get("name") or "Passkey")[:80]))
conn.commit()
except Exception:
raise HTTPException(409, "Credential already registered") from None
kid = cur.lastrowid
return {"id": kid, "status": "registered"}
@router.post("/login/begin")
async def login_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_authentication_options, options_to_json
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
if not login:
raise HTTPException(400, "login required")
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not user or not user["is_active"]:
raise HTTPException(401, "Invalid credentials")
creds = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
if not creds:
raise HTTPException(400, "No passkeys for this account")
rp_id, _origin = _rp(request)
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
for r in creds]
options = generate_authentication_options(rp_id=rp_id, allow_credentials=allow)
_store_challenge(f"login:{login}", options.challenge)
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
@router.post("/login/finish")
async def login_finish(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_authentication_response
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
challenge = _take_challenge(f"login:{login}")
if not login or not challenge:
raise HTTPException(400, "Challenge expired — begin again")
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not user or not user["is_active"]:
raise HTTPException(401, "Invalid credentials")
stored = conn.execute("SELECT * FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
rp_id, origin = _rp(request)
credential = body.get("credential") or {}
cred_id = (credential.get("id") or "").rstrip("=")
match = next((dict(r) for r in stored if r["credential_id"].rstrip("=") == cred_id), None)
if not match:
raise HTTPException(401, "Unknown credential")
import base64
try:
verified = verify_authentication_response(
credential=credential, expected_challenge=challenge,
expected_origin=origin, expected_rp_id=rp_id,
credential_public_key=base64.b64decode(match["public_key"]),
credential_current_sign_count=match["sign_count"],
require_user_verification=False)
except Exception as exc: # noqa: BLE001
raise HTTPException(401, f"Authentication rejected: {exc}") from None
with get_conn() as conn:
conn.execute("UPDATE webauthn_credentials SET sign_count=? WHERE id=?",
(verified.new_sign_count, match["id"]))
conn.execute("UPDATE users SET last_login=? WHERE id=?",
(str(time.time()), user["id"]))
conn.commit()
ud = dict(conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone())
session = SessionManager.create_session(ud, request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
response.set_cookie("flowdeck_session", session, httponly=True,
max_age=86400 * 7, samesite="lax", path="/")
return response
@router.get("/keys")
async 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"
" WHERE user_id=? ORDER BY id", (user["id"],)).fetchall()
return {"keys": [dict(r) for r in rows]}
@router.delete("/keys/{key_id}")
async 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=?",
(key_id, user["id"]))
conn.commit()
if not cur.rowcount:
raise HTTPException(404, "Key not found")
return {"status": "deleted", "id": key_id}
def _b64url_to_bytes(data: str) -> bytes:
import base64
padded = data + "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(padded)
def reset_challenges() -> None:
_challenges.clear()
__all__ = ["router", "reset_challenges", "secrets"]
+537
View File
@@ -0,0 +1,537 @@
"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0).
Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``.
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
"""
from __future__ import annotations
import html
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import wiki
from app.services.api_v2_helpers import audit_log
from app.services.notifications import create_notification
from app.templating import ENV
router = APIRouter(tags=["wiki"])
def _esc(value) -> str:
return html.escape(str(value))
def _user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not sess or not sess.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin, is_active FROM users WHERE id=?",
(sess["id"],)).fetchone()
if not row or not row["is_active"]:
raise HTTPException(403, "Account disabled")
return sess
def _workspace_id(request: Request) -> int:
wid = request.query_params.get("workspace_id")
if not wid:
raise HTTPException(400, "workspace_id required")
try:
wid = int(wid)
except (TypeError, ValueError):
raise HTTPException(400, "invalid workspace_id") from None
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM workspaces WHERE id=?", (wid,)).fetchone():
raise HTTPException(404, "Workspace not found")
return wid
def _teamspace_or_404(teamspace_id: int, user_id: int) -> dict:
with get_conn() as conn:
row = conn.execute("SELECT * FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone()
if not row:
raise HTTPException(404, "Teamspace not found")
if not wiki.can_read_teamspace(user_id, teamspace_id):
# private teamspace → 404 (not 403), same as restricted collections
raise HTTPException(404, "Teamspace not found")
return dict(row)
def _page_or_404(page_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, workspace_id, teamspace_id, deleted_at FROM pages WHERE id=?",
(page_id,)).fetchone()
if not row or row["deleted_at"]:
raise HTTPException(404, "Page not found")
return dict(row)
def _is_admin(user: dict) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
return bool(row and row["is_admin"])
def _can_verify(user: dict, page: dict) -> bool:
"""Admin, or an editor/owner of the teamspace / workspace holding the page."""
if _is_admin(user):
return True
if page.get("teamspace_id"):
return wiki.can_write_teamspace(user["id"], page["teamspace_id"])
wid = page.get("workspace_id")
if not wid:
return False
with get_conn() as conn:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
if owner and owner["owner_id"] == user["id"]:
return True
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(wid, user["id"])).fetchone()
return bool(member and member["role"] in ("owner", "admin", "editor"))
# ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request):
user = _user(request)
wid = request.query_params.get("workspace_id")
if wid:
try:
wid = int(wid)
except (TypeError, ValueError):
raise HTTPException(400, "invalid workspace_id") from None
else:
wid = None
return {"teamspaces": wiki.list_teamspaces(user["id"], wid)}
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async 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"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
with get_conn() as conn:
ws = conn.execute("SELECT name FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
pages = wiki.teamspace_pages(teamspace_id)
collections = wiki.teamspace_collections(teamspace_id)
page_rows = "\n".join(
f'<a class="ts-row" href="/pages/{p["id"]}" style="display:flex;align-items:center;gap:8px;'
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
f'<span>📄</span><span>{_esc(p["title"] or "Untitled")}</span></a>'
for p in pages)
coll_rows = "\n".join(
f'<a class="ts-row" href="/db/{c["id"]}" style="display:flex;align-items:center;gap:8px;'
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
f'<span>{_esc(c["icon"] or "🗄️")}</span><span>{_esc(c["name"] or "Untitled")}</span></a>'
for c in collections)
content_html = f"""
<div style="max-width:860px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:26px;display:flex;align-items:center;gap:10px;">
{_esc(ts['name'])}{' <span style="font-size:13px;padding:2px 8px;border-radius:10px;background:rgba(76,154,255,.15);color:#4c9aff;">🔒 private</span>' if ts['private'] else ''}
</h1>
<p style="color:var(--text-dim);">{_esc(ts.get('description') or '')}</p>
<div style="display:flex;gap:10px;font-size:12px;color:var(--text-dim);margin-bottom:24px;flex-wrap:wrap;">
<span>Workspace: {_esc((ws["name"]) if ws else '')}</span>
<span>·</span><span>Role: {_esc(ts['role'])}</span>
<span>·</span><span>{len(pages) + len(collections)} items</span>
</div>
<h2 style="font-size:16px;margin:20px 0 8px;">Pages</h2>
<div style="display:flex;flex-direction:column;gap:4px;">
{page_rows or '<p style="color:var(--text-dim);font-size:13px;">No pages yet.</p>'}
</div>
<h2 style="font-size:16px;margin:24px 0 8px;">Databases</h2>
<div style="display:flex;flex-direction:column;gap:4px;">
{coll_rows or '<p style="color:var(--text-dim);font-size:13px;">No databases yet.</p>'}
</div>
</div>
<style>
.ts-row:hover{{background:var(--bg-hover);}}
</style>"""
from app.routers.dashboard import _sidebar_data
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
page_title=ts["name"],
title_prefix="Teamspace",
page_icon="🏛️",
)
@router.post("/api/v2/wiki/teamspaces")
async def create_teamspace(request: Request):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name or len(name) > 120:
raise HTTPException(400, "name required (max 120 chars)")
wid = int(body.get("workspace_id") or 0)
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(wid, user["id"])).fetchone()
allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"])
or (member and member["role"] in ("admin", "editor", "owner")))
if not allowed:
raise HTTPException(403, "Editor role required in the workspace")
try:
tsid = wiki.create_teamspace(wid, name, user["id"],
description=body.get("description") or "",
private=bool(body.get("private")))
except ValueError as exc:
raise HTTPException(409, str(exc)) from None
audit_log(user, "teamspace.create", "teamspace", tsid, name, request)
return JSONResponse(status_code=201, content={"id": tsid, "name": name})
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async 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)
ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id))
return ts
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn:
rows = conn.execute(
"""SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m
JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""",
(teamspace_id,)).fetchall()
return {"members": [dict(r) for r in rows]}
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def set_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):
raise HTTPException(403, "Editor role required")
try:
body = await request.json()
except Exception:
body = {}
role = body.get("role")
if role not in wiki.TEAMSPACE_ROLES:
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone():
raise HTTPException(404, "User not found")
conn.execute(
"""INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?)
ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""",
(teamspace_id, member_id, role))
conn.commit()
audit_log(user, "teamspace.member.set", "teamspace", teamspace_id,
f"u{member_id}={role}", request)
return {"status": "ok", "user_id": member_id, "role": role}
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async 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):
raise HTTPException(403, "Editor role required")
with get_conn() as conn:
cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, member_id))
conn.commit()
if not cur.rowcount:
raise HTTPException(404, "Not a member")
return {"status": "removed", "user_id": member_id}
# ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
return {"verification": wiki.verification(page_id)}
@router.post("/api/v2/wiki/pages/{page_id}/verify")
async def verify_page(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if not _can_verify(user, page):
raise HTTPException(403, "Editor role required to verify a page")
try:
body = await request.json()
except Exception:
body = {}
out = wiki.verify_page(page_id, user["id"],
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
note=body.get("note") or "")
audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request)
return {"verification": out}
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
if not wiki.unverify_page(page_id):
raise HTTPException(404, "Page is not verified")
audit_log(user, "page.unverify", "page", page_id, "", request)
return {"status": "unverified", "page_id": page_id}
@router.get("/api/v2/wiki/verified")
async def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request)
wid = _workspace_id(request)
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.page_icon, p.teamspace_id,
v.verified_at, v.expires_at, v.note, u.login
FROM page_verifications v
JOIN pages p ON p.id = v.page_id
LEFT JOIN users u ON u.id = v.verified_by
WHERE p.workspace_id=? AND p.deleted_at IS NULL""",
(wid,)).fetchall()
out = []
for r in rows:
item = dict(r)
if wiki.is_expired(r):
continue # badge lapsed → not listed
if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"],
item["teamspace_id"]):
continue # private teamspace → hidden
item["active"] = True
out.append(item)
return {"pages": out}
# ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"])
return {"page_id": page_id, "following": now}
@router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
ids = wiki.followers(page_id)
if not ids:
return {"followers": []}
with get_conn() as conn:
rows = conn.execute(
f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})",
ids).fetchall()
return {"followers": [dict(r) for r in rows]}
# ── comment reactions ──────────────────────────────────────────────────────
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
async def react(comment_id: int, request: Request):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
emoji = (body.get("emoji") or "").strip()
if not emoji:
raise HTTPException(400, "emoji required")
try:
counts = wiki.toggle_reaction(comment_id, user["id"], emoji)
except LookupError:
raise HTTPException(404, "Comment not found") from None
return {"comment_id": comment_id, "reactions": counts}
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request):
_user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
# ── guest shares ───────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/guests")
async def create_guest(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"]):
raise HTTPException(403, "Editor role required to share")
try:
body = await request.json()
except Exception:
body = {}
try:
share = wiki.create_guest_share(page_id, body.get("email") or "",
body.get("role") or "viewer",
user["id"], days=body.get("days", 30))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "page.guest_share", "page", page_id, share["email"], request)
return JSONResponse(status_code=201, content={
"id": share["id"], "token": share["token"], "role": share["role"],
"expires_at": share["expires_at"], "url": f"/g/{share['token']}"})
@router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares"
" WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall()
return {"guests": [dict(r) for r in rows]}
@router.delete("/api/v2/wiki/guests/{share_id}")
async 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():
raise HTTPException(404, "Guest share not found")
conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,))
conn.commit()
audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request)
return {"status": "revoked", "id": share_id}
_GUEST_404 = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Link unavailable — FlowDeck</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:520px;
margin:80px auto;padding:0 20px;color:#1f2328;text-align:center}
h1{font-size:20px} p{color:#656d76;line-height:1.6}</style></head><body>
<h1>This link is unavailable</h1>
<p>It may have expired, been revoked, or never existed.<br>
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):
"""Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token)
if not share:
return HTMLResponse(_GUEST_404, status_code=404)
with get_conn() as conn:
page = conn.execute("SELECT id, title, content, created_at, updated_at, deleted_at"
" FROM pages WHERE id=?", (share["page_id"],)).fetchone()
if not page or page["deleted_at"]:
return HTMLResponse(_GUEST_404, status_code=404)
wiki.record_view(share["page_id"])
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{page['title']} — FlowDeck guest</title>
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
margin:40px auto;padding:0 20px;line-height:1.6;color:#1f2328}}
.guest-banner{{background:#fff4e5;border:1px solid #ffd8a8;padding:10px 14px;
border-radius:8px;margin-bottom:24px;font-size:14px}}
pre{{background:#f6f8fa;padding:14px;border-radius:8px;overflow:auto;
white-space:pre-wrap;word-break:break-word}}</style></head><body>
<div class="guest-banner">You are viewing this page as a guest
({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}).
Editing is disabled.</div>
<h1>{page['title']}</h1><pre>{page['content'] or ''}</pre></body></html>"""
# ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views")
async 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"]):
raise HTTPException(403, "Editor role required to read analytics")
return wiki.view_stats(page_id, days=request.query_params.get("days", 30))
# ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home")
async def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request)
wid = _workspace_id(request)
with get_conn() as conn:
recents = conn.execute(
"""SELECT id, title, page_icon, updated_at FROM pages
WHERE workspace_id=? AND deleted_at IS NULL
ORDER BY updated_at DESC LIMIT 20""", (wid,)).fetchall()
verified = conn.execute(
"""SELECT v.page_id, v.verified_at, v.expires_at FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE p.workspace_id=? AND p.deleted_at IS NULL
AND (v.expires_at IS NULL OR v.expires_at > ?)""",
(wid, __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).replace(microsecond=0).isoformat()),
).fetchall()
return {"workspace_id": wid,
"teamspaces": wiki.list_teamspaces(user["id"], wid),
"verified": [dict(r) for r in verified],
"recents": [dict(r) for r in recents]}
@router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request)
with get_conn() as conn:
if not conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()[0]:
raise HTTPException(403, "Admin required")
sent = 0
for row in wiki.expiring_verifications(days=7):
create_notification(row["owner_id"], None, "page.verification_expiring",
"Verification expiring soon",
f"“{row['title']}” loses its verified badge on {row['expires_at']}.",
resource_type="page", resource_id=row["page_id"])
sent += 1
return {"notified": sent}
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
@router.post("/api/v2/wiki/blocks/preview")
async def preview_blocks(request: Request):
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
_user(request)
try:
body = await request.json()
except Exception:
body = {}
blocks = body.get("blocks")
if not isinstance(blocks, list):
raise HTTPException(400, "blocks must be a list")
if len(blocks) > 200:
raise HTTPException(400, "max 200 blocks per preview")
from app.services.wiki_blocks import mmdc_available, render_block
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
return {"rendered": out, "mmdc_available": mmdc_available()}
+216
View File
@@ -0,0 +1,216 @@
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import workers as worker_service
from app.services.api_v2_helpers import (
audit_log,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
row_to_dict,
)
router = APIRouter(tags=["workers"])
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
def _row_to_api(row) -> dict:
d = row_to_dict(row)
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
return d
@router.post("/api/v2/workers")
async def create_worker(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Untitled worker").strip()[:200]
code = body.get("code_py") or ""
try:
worker_service.validate_code(code)
except worker_service.WorkerRejected as exc:
raise HTTPException(400, f"code rejected: {exc}") from None
slug = worker_service.unique_slug(body.get("slug") or name)
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
shared, daily_budget_s, created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(slug, body.get("workspace_id"), name, code,
(body.get("schedule_cron") or "")[:60],
1 if body.get("shared") else 0,
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
user["id"]))
conn.commit()
wid = cur.lastrowid
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
audit_log(user, "worker.create", "worker", wid, slug, request)
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
@router.get("/api/v2/workers")
async def list_workers(request: Request):
_auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
rows = conn.execute(
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
resp = JSONResponse([_row_to_api(r) for r in rows])
for k, v in paginate_headers(total).items():
resp.headers[k] = v
return resp
@router.get("/api/v2/workers/{worker_id}")
async 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()
if not row:
raise HTTPException(404, "Worker not found")
out = _row_to_api(row)
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
or user.get("is_admin")):
out["code_py"] = row["code_py"]
return out
@router.patch("/api/v2/workers/{worker_id}")
async def update_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the owner can update this worker")
updates: dict = {}
if "name" in body:
updates["name"] = str(body["name"] or "")[:200]
if "code_py" in body:
try:
worker_service.validate_code(body["code_py"] or "")
except worker_service.WorkerRejected as exc:
raise HTTPException(400, f"code rejected: {exc}") from None
updates["code_py"] = body["code_py"] or ""
if "schedule_cron" in body:
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
if "shared" in body:
updates["shared"] = 1 if body["shared"] else 0
if "daily_budget_s" in body:
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
if "slug" in body and body["slug"] != row["slug"]:
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
raise HTTPException(400, "Invalid slug")
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
(body["slug"], worker_id)).fetchone():
raise HTTPException(409, "Slug already taken")
updates["slug"] = body["slug"]
if updates:
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(*updates.values(), worker_id))
conn.commit()
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
return _row_to_api(row)
@router.delete("/api/v2/workers/{worker_id}")
async 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()
if not row:
raise HTTPException(404, "Worker not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the owner can delete this worker")
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
conn.commit()
audit_log(user, "worker.delete", "worker", worker_id, "", request)
return {"status": "deleted", "id": worker_id}
@router.post("/api/v2/workers/{worker_id}/run")
async def run_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json() if request.headers.get("content-type") else {}
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
if (row["created_by"] != user["id"] and not row["shared"]
and not user.get("is_admin")):
raise HTTPException(403, "Worker is private")
import asyncio
loop = asyncio.get_running_loop()
try:
out = await loop.run_in_executor(
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
except HTTPException:
raise
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
return out
@router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request):
_auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn:
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
raise HTTPException(404, "Worker not found")
rows = conn.execute(
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
(worker_id, limit)).fetchall()
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
@router.post("/api/v2/workers/{worker_id}/fork")
async 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)
return JSONResponse(status_code=201, content=out)
@router.get("/api/v2/workers-usage")
async 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
return {"workspace_id": wid,
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
"user_id": user.get("id")}
+69 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import csv
import html
import io
import json
import logging
@@ -25,15 +26,36 @@ def _current_user(request: Request) -> dict:
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
# ── Workspaces ──
def _require_admin(request: Request) -> dict:
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
if not user.get("is_admin"):
raise HTTPException(403, "Admin only")
return user
@router.get("")
async def list_workspaces(request: Request):
def _require_ws_admin(request: Request, ws_id: int) -> None:
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
promouvoir admin."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
return {"workspaces": [dict(r) for r in rows]}
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
return
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ws_id, user["id"]),
).fetchone()
if not row or row["role"] != "admin":
raise HTTPException(403, "Workspace admin role required")
# ── Workspaces ──
@router.post("")
async def create_workspace(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
@@ -58,6 +80,8 @@ async def create_workspace(request: Request):
@router.get("/{ws_id}/members")
async 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:
rows = conn.execute(
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
@@ -68,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
@router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
(user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role))
@@ -84,6 +109,7 @@ async def add_member(request: Request, ws_id: int):
@router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor")
if role not in ROLES:
@@ -97,6 +123,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):
_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))
conn.commit()
@@ -133,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"}
@@ -153,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"status": "updated"}
@@ -219,7 +246,7 @@ async def add_favorite(request: Request):
try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
except Exception:
pass
logger.exception("add_favorite")
return {"status": "favorited"}
@@ -466,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
except Exception:
pass
logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -496,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
except Exception:
pass
logger.exception("update_sprint")
return {"id": sid, "status": "updated"}
@@ -664,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
@router.get("/webhooks")
async 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()
return {"webhooks": [dict(r) for r in rows]}
@@ -671,12 +699,20 @@ async def list_webhooks(request: Request):
@router.post("/webhooks")
async def create_webhook(request: Request):
_require_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
if not url:
raise HTTPException(400, "url required")
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
from urllib.parse import urlparse
from app.services.importers.url_fetch import _is_public_host
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
@@ -688,6 +724,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}")
async 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,))
conn.commit()
@@ -698,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required."""
"""Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
sur le titre de la base ou d'une ligne).
"""
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
if ptype in ("restricted", "private"):
# 404 explicite : le handler global transformerait un HTTPException(404)
# en redirection 302 → login pour un chemin HTML.
return HTMLResponse(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
"<body><h1>404 — Not found</h1></body></html>",
status_code=404,
)
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
esc = html.escape
name = esc(str(coll["name"] or ""))
icon = esc(str(coll["icon"] or ""))
items = "".join(
f"<li>{p['icon']} <b>{p['title']}</b></li>"
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
for p in pages
)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
+29 -3
View File
@@ -18,9 +18,10 @@ import re
from app.config import settings
from app.db import get_conn
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.permission_manager import WRITE_TOOLS, PermissionManager
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -185,7 +186,10 @@ class AgentEngine:
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
# v7.2.0 — the workspace policy may cap iterations below the global max.
policy_max = get_policy(self.workspace_id).get("max_steps") or MAX_ITERATIONS
iterations = min(settings.agent_max_iterations or MAX_ITERATIONS, policy_max)
for _step in range(iterations):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
@@ -235,8 +239,30 @@ class AgentEngine:
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
# v7.2.0 — workspace tool scope + human approval gate, checked
# *before* permissions so a scoped-out tool never reaches ACLs.
gov = check_tool(self.user_id, self.workspace_id, tool,
is_write=tool in WRITE_TOOLS,
conversation_id=conversation_id)
if not gov.get("allowed"):
detail = gov.get("reason") or "Refusé par la politique agent"
if gov.get("approval_id"):
detail = (f"Approbation requise (demande #{gov['approval_id']}) "
f"— action suspendue")
yield self._event("action", {
"tool": tool, "status": "approval_required" if gov.get("approval_id")
else "error", "detail": detail,
"approval_id": gov.get("approval_id")})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": detail},
ensure_ascii=False),
})
denied = True
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
if not denied:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
+89
View File
@@ -0,0 +1,89 @@
"""FlowDeck — agent governance (v7.2.0): workspace tool scope + approval gate.
``agent_policies``: ``allowed_tools_json`` (null = all tools), ``max_steps``,
``require_approval`` (write tools pause for a human). ``check_tool()`` is
consulted by ``AgentEngine`` before ``PermissionManager.assert_can``.
``agent.run.approval_requested`` is emitted on the outbound webhook bus so
external systems can subscribe. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import json
from app.db import get_conn
def get_policy(workspace_id: int | None) -> dict:
"""Effective policy (workspace row, else global row, else defaults)."""
with get_conn() as conn:
row = None
if workspace_id is not None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id=?",
(workspace_id,)).fetchone()
if row is None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS NULL"
).fetchone()
if not row:
return {"allowed_tools": None, "max_steps": 12, "require_approval": False}
d = dict(row)
try:
allowed = json.loads(d.get("allowed_tools_json")) if d.get("allowed_tools_json") else None
except (TypeError, ValueError):
allowed = None
return {"allowed_tools": allowed, "max_steps": d.get("max_steps") or 12,
"require_approval": bool(d.get("require_approval"))}
def check_tool(user_id: int, workspace_id: int | None, tool: str,
is_write: bool, conversation_id: int = 0) -> dict:
"""Policy gate for one tool call.
Returns {allowed: bool, approval_id: int|None}. Denied tools and gated
writes (pending approval) return allowed=False; the engine renders both
as action errors without executing.
"""
from app.services.permission_manager import WRITE_TOOLS
policy = get_policy(workspace_id)
allowed = policy["allowed_tools"]
if allowed is not None and tool not in set(allowed):
return {"allowed": False, "approval_id": None, "reason": "tool not in policy scope"}
if is_write or tool in WRITE_TOOLS:
if policy["require_approval"]:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO agent_approvals
(conversation_id, tool, args_json, status, requester_id)
VALUES (?,?,?,?,?)""",
(conversation_id, tool, "{}", "pending", user_id))
conn.commit()
approval_id = cur.lastrowid
try:
import asyncio
from app.services.webhook_outbound import fire_event as _fire
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(_fire("agent.run.approval_requested", {
"approval_id": approval_id, "tool": tool,
"conversation_id": conversation_id}))
except Exception: # noqa: BLE001 — webhook never blocks policy
pass
return {"allowed": False, "approval_id": approval_id,
"reason": "approval requested"}
return {"allowed": True, "approval_id": None, "reason": ""}
def decide_approval(approval_id: int, approver_id: int, approve: bool) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone()
if not row or row["status"] != "pending":
return None
conn.execute("UPDATE agent_approvals SET status=?, approver_id=? WHERE id=?",
("approved" if approve else "rejected", approver_id, approval_id))
conn.commit()
return dict(conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone())
+8 -5
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import time
from datetime import UTC, datetime
from typing import Any
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None:
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception:
pass
logger.exception("row_to_dict")
return d
# ── Pagination ────────────────────────────────────────────────────────────
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
if dt.timestamp() < time.time():
return None
except Exception:
pass
logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
d = dict(u)
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
except Exception:
pass
logger.exception("resolve_bearer_token")
return d
# 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
@@ -257,7 +260,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
)
conn.commit()
except Exception:
pass
logger.exception("audit_log")
# ── Rate limit per token (in-memory) ─────────────────────────────────────
@@ -307,4 +310,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
)
conn.commit()
except Exception:
pass
logger.exception("store_idempotency")
+423 -2
View File
@@ -23,7 +23,8 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
import time
from datetime import UTC, datetime, timedelta
import httpx
@@ -167,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
url = action.get("url", "").strip()
if not url:
raise ValueError("webhook action requires a url")
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
from urllib.parse import urlparse as _urlparse
from app.services.importers.url_fetch import _is_public_host
_parsed = _urlparse(url)
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
secret = action.get("secret", "")
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret:
@@ -246,6 +254,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
)
return f"notified user {user_id}"
if atype == "slack":
url = _secret_value(action.get("webhook_url") or action.get("url") or "")
if not url:
raise ValueError("slack action requires a webhook_url")
_, text = _maybe_convert_prediction(
action.get("text") or action.get("message") or "Automation fired", context)
return await _post_slack(url, text)
if atype == "email":
to = action.get("to", "")
_, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context)
_, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context)
return await _send_email_action(to, subject, body, context)
if atype == "forge_issue":
provider = (action.get("provider") or "gitea").lower()
owner = action.get("owner", "")
repo = action.get("repo", "")
if not owner or not repo:
raise ValueError("forge_issue requires owner + repo")
_, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context)
_, body = _maybe_convert_prediction(action.get("body", ""), context)
return await _create_forge_issue(
provider, owner, repo, title, body,
labels=action.get("labels") or [],
user_id=context.get("created_by"),
)
if atype == "agent_trigger":
agent_id = action.get("agent_id")
if not agent_id:
raise ValueError("agent_trigger requires an agent_id")
_, message = _maybe_convert_prediction(action.get("message", ""), context)
return await _run_linked_agent(
int(agent_id), context.get("created_by") or 1,
context.get("workspace_id"), message, context)
raise ValueError(f"unknown action type: {atype!r}")
@@ -260,6 +305,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
if not auto["enabled"]:
return {"status": "skipped", "detail": "automation disabled"}
# v7.0.0: chained steps take over when present (legacy path otherwise).
stepped = await _maybe_run_stepped(auto, trigger_source, context)
if stepped is not None:
return stepped
props = context.get("properties")
before = context.get("before_properties")
if not evaluate_conditions(auto["condition_json"], props, before):
@@ -307,6 +357,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
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).
if event == "page.updated":
try:
from app.services.wiki import notify_followers_of_page_update
notify_followers_of_page_update(
payload.get("page_id"), payload.get("actor_id"),
payload.get("title") or "")
except Exception: # noqa: BLE001 — notifications are best-effort
logger.debug("followers notification failed for page.updated")
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
try:
from app.services.webhook_outbound import fire_event as fire_webhooks
@@ -320,14 +379,29 @@ async def fire_event(event: str, payload: dict):
WHERE trigger_type='event' AND event=? AND enabled=1""",
(event,),
).fetchall()
stepped_ids: set[int] = set()
try:
with get_conn() as _c:
stepped_ids = {r[0] for r in _c.execute(
"SELECT DISTINCT automation_id FROM automation_steps").fetchall()}
except Exception: # noqa: BLE001 — table missing on very old DBs
pass
for row in rows:
auto = dict(row)
if auto["id"] in stepped_ids:
continue # v7.0.0: handled by fire_stepped_event below (no double run)
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
continue
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# v7.0.0: step-based automations (multi-trigger any/all, chains).
try:
await fire_stepped_event(event, payload)
except Exception: # noqa: BLE001
logger.debug("stepped dispatch failed for %s", event)
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
@@ -346,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
expr = (expression or "").strip().lower()
if not expr:
return False
now = now or datetime.utcnow()
now = now or datetime.now(UTC).replace(tzinfo=None)
minute = now.minute
fields = expr.split()
@@ -409,6 +483,353 @@ async def automation_scheduler():
await run_automation(auto["id"], "cron", context)
except Exception: # noqa: BLE001
logger.warning("Cron automation %s errored", auto["id"])
# v7.0.0: workers on a cron schedule share the same 60s loop.
try:
from app.services.workers import run_due_workers
await run_due_workers()
except Exception: # noqa: BLE001
logger.warning("worker cron iteration failed")
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
STEP_KINDS = ("trigger", "condition", "delay", "action")
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
"slack", "email", "forge_issue", "agent_trigger")
ALL_MODE_WINDOW_S = 300.0
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
_ALL_PENDING: dict[int, dict[str, float]] = {}
def reset_all_pending() -> None:
"""Test helper: clear the mode=all arrival window."""
_ALL_PENDING.clear()
def _secret_value(stored: str | None) -> str:
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
if not stored:
return ""
try:
from app.services.sso_provisioning import decrypt_secret
decrypted = decrypt_secret(stored)
if decrypted:
return decrypted
except Exception: # noqa: BLE001
pass
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
return stored
return ""
def validate_step(kind: str, config: dict) -> None:
"""Validate a step payload. Raises ValueError with a human message."""
from fastapi import HTTPException
if kind not in STEP_KINDS:
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
config = config or {}
if kind == "trigger":
if not config.get("event"):
raise HTTPException(400, "trigger step requires an event")
elif kind == "condition":
if config.get("op", "eq") not in COND_OPS:
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
elif kind == "delay":
try:
seconds = int(config.get("seconds", 0))
except (TypeError, ValueError):
raise HTTPException(400, "delay step requires integer seconds") from None
if seconds < 0 or seconds > 86400:
raise HTTPException(400, "delay seconds must be 0..86400")
elif kind == "action":
if config.get("type") not in STEP_ACTION_TYPES:
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
def get_steps(automation_id: int) -> list[dict]:
"""Ordered steps of an automation (empty when legacy single-mode)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
(automation_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
try:
d["config"] = json.loads(d.get("config_json") or "{}")
except (TypeError, json.JSONDecodeError):
d["config"] = {}
out.append(d)
return out
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
"delay": [], "action": []}
for s in steps:
if s.get("kind") in grouped:
grouped[s["kind"]].append(s)
return grouped
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
automation_collection_id: int | None) -> bool:
if step_cfg.get("event") != event:
return False
want_coll = step_cfg.get("collection_id") or automation_collection_id
if want_coll and payload.get("collection_id") != want_coll:
return False
return True
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
context: dict) -> dict:
"""Execute a chained automation. Records one run row with per-step detail."""
grouped = _steps_by_kind(steps)
props = context.get("properties")
before = context.get("before_properties")
# Legacy single condition still applies on top of step conditions.
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "condition not met"}
for cond in grouped["condition"]:
cfg = cond.get("config") or {}
if not match_condition_props(props, before, {
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
"value": cfg.get("value")}):
_save_run(auto["id"], trigger_source, "skipped",
f"step condition not met: {cfg.get('property')}",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "step condition not met"}
ctx = dict(context)
ctx["automation_name"] = auto["name"]
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
results = []
try:
for step in ordered:
kind = step.get("kind")
cfg = step.get("config") or {}
if kind in ("trigger", "condition"):
continue
if kind == "delay":
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
if seconds:
await asyncio.sleep(seconds)
results.append(f"delay {cfg.get('seconds', 0)}s")
elif kind == "action":
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
trigger_source)
results.append(summary)
detail = "; ".join(results) or "no steps executed"
_save_run(auto["id"], trigger_source, "fired", detail,
ctx.get("collection_id"), ctx.get("page_id"))
try:
from app.services.webhook_outbound import fire_event as _fire_wh
await _fire_wh("automation.fired", {
"automation_id": auto["id"], "name": auto["name"],
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
"page_id": ctx.get("page_id"), "detail": detail})
except Exception: # noqa: BLE001
logger.debug("automation.fired webhook dispatch failed")
return {"status": "fired", "detail": detail}
except Exception as exc: # noqa: BLE001
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
_save_run(auto["id"], trigger_source, "error", str(exc),
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "error", "detail": str(exc)}
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
"""Run via steps when the automation has any; None → use legacy path."""
steps = get_steps(auto["id"])
if not steps:
return None
return await _run_with_steps(auto, steps, trigger_source, context)
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT a.* FROM automations a
JOIN automation_steps s ON s.automation_id = a.id
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
).fetchall()
matched = []
for row in rows:
auto = dict(row)
steps = get_steps(auto["id"])
triggers = [s for s in steps if s.get("kind") == "trigger"]
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
auto.get("collection_id")) for t in triggers):
matched.append((auto, triggers))
return matched
async def fire_stepped_event(event: str, payload: dict) -> None:
"""Dispatch ``event`` to step-based automations (mode any/all).
Called from :func:`fire_event` after the legacy matcher. Unknown events
(not in the webhook catalogue) still work here — steps are independent
from outbound webhooks.
"""
now = time.time()
for auto, triggers in _match_stepped_automations(event, payload):
# Skip automations already handled by the legacy matcher to avoid
# double runs (legacy = trigger_type event + no steps).
if not get_steps(auto["id"]):
continue
mode = (auto.get("trigger_mode") or "any").lower()
if mode == "all":
pending = _ALL_PENDING.setdefault(auto["id"], {})
pending[event] = now
# Expire arrivals outside the window.
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
del pending[ev]
wanted = {t.get("config", {}).get("event") for t in triggers}
if not wanted <= set(pending):
continue
_ALL_PENDING.pop(auto["id"], None)
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
async def _post_slack(webhook_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json={"text": text})
if resp.status_code >= 400:
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
return f"slack → ({resp.status_code})"
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
from app.services import mailer
address = (to or "").strip()
if address.startswith("user:"):
try:
uid = int(address.split(":", 1)[1])
except ValueError:
raise ValueError(f"bad email target: {to!r}") from None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
address = (row["email"] if row and row["email"] else "")
if not address:
raise ValueError(f"user {uid} has no email")
if not address:
address = None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?",
(context.get("created_by") or 1,)).fetchone()
if row and row["email"]:
address = row["email"]
if not address:
return "email skipped (no recipient)"
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
body: str, labels: list | None = None,
user_id: int | None = None) -> str:
token = ""
if user_id:
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
(user_id, provider)).fetchone()
token = (row["access_token"] if row else "") or ""
if provider == "github":
if not token:
raise ValueError("github action needs a linked GitHub account (token)")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues",
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"},
json={"title": title, "body": body,
"labels": labels or []} if labels else {"title": title, "body": body},
)
if resp.status_code >= 400:
raise RuntimeError(f"github returned HTTP {resp.status_code}")
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
# gitea (default)
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token or None)
issue = await gitea.create_issue(owner, repo, title, body)
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
message: str, context: dict) -> str:
from app.services.agent_engine import AgentEngine
with get_conn() as conn:
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not agent:
raise ValueError(f"agent {agent_id} not found")
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent_id, user_id,
f"Automation: {context.get('automation_name', 'run')}",
json.dumps({"workspace_id": workspace_id})),
)
conv_id = cur.lastrowid
conn.commit()
objective = ((agent["system_instructions"] or "").strip()
or f"Exécute l'agent « {agent['name']} ».")
if message:
objective = f"{objective}\n\n{message}"
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
final = ""
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
final = (row["content"][:300] if row and row["content"] else "")
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
# ── v7.0.0 native DB button ────────────────────────────────────────────────
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
user_id: int) -> dict:
"""Run the automation linked to a ``button`` property cell."""
with get_conn() as conn:
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
(int(prop_ref), collection_id)).fetchone()
else:
prop = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, prop_ref)).fetchone()
if not prop:
raise ValueError("button property not found")
prop = dict(prop)
if prop.get("prop_type") != "button":
raise ValueError("property is not a button")
auto_id = prop.get("button_automation_id")
if not auto_id:
raise ValueError("button has no linked automation")
row = conn.execute(
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
(row_id, collection_id)).fetchone()
if not row:
raise ValueError("row not found")
context = get_page_context(row_id, collection_id)
context["created_by"] = user_id
return await run_automation(auto_id, "button", context)
+1 -1
View File
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
logger.exception("backup_db")
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+528
View File
@@ -0,0 +1,528 @@
"""FlowDeck — external calendar sync (v7.1.0).
Bidirectional sync between a collection (date property) and an external
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
extra dependency). Tokens are Fernet-encrypted at rest.
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
Conflicts (both sides changed since ``last_sync``): last-write-wins +
in-app ``calendar.conflict`` notification (manual edit resolves).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn
logger = logging.getLogger(__name__)
PROVIDERS = ("google", "caldav")
SYNC_LOOKBACK_DAYS = 30
SYNC_LOOKAHEAD_DAYS = 90
class SyncError(RuntimeError):
"""Raised when the remote calendar cannot be reached/authorized."""
# ── links ──────────────────────────────────────────────────────────────────
def _encrypt_tokens(creds: dict) -> str:
from app.services.sso_provisioning import encrypt_secret
return encrypt_secret(json.dumps(creds or {}))
def _decrypt_tokens(tokens_enc: str) -> dict:
if not tokens_enc:
return {}
try:
from app.services.sso_provisioning import decrypt_secret
raw = decrypt_secret(tokens_enc)
if raw:
return json.loads(raw)
except Exception: # noqa: BLE001
pass
try: # legacy plaintext (tests)
data = json.loads(tokens_enc)
return data if isinstance(data, dict) else {}
except Exception: # noqa: BLE001
return {}
def save_link(user_id: int, provider: str, collection_id: int,
credentials: dict, calendar_id: str = "primary",
date_property: str = "") -> dict:
if provider not in PROVIDERS:
raise ValueError(f"provider must be google|caldav, got {provider!r}")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?",
(collection_id,)).fetchone():
raise ValueError("collection not found")
cur = conn.execute(
"""INSERT INTO calendar_links
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
VALUES (?,?,?,?,?,?)
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
date_property=excluded.date_property""",
(user_id, provider, _encrypt_tokens(credentials),
calendar_id or "primary", collection_id, date_property or ""))
conn.commit()
row = conn.execute(
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
(user_id, provider, calendar_id or "primary")).fetchone()
_ = cur
out = dict(row)
out.pop("tokens_enc", None)
return out
def list_links(user_id: int) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, user_id, provider, calendar_id, collection_id,"
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
" ORDER BY id", (user_id,)).fetchall()
return [dict(r) for r in rows]
def delete_link(user_id: int, link_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
(link_id, user_id))
conn.commit()
return cur.rowcount > 0
def _load_link(link_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
return dict(row) if row else None
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
def _remote_event(eid: str, title: str, start: str, description: str = "",
updated: str = "") -> dict:
return {"id": str(eid), "title": title or "Untitled", "start": start,
"description": description or "", "updated": updated or ""}
async def google_list_events(tokens: dict, calendar_id: str,
time_min: str, time_max: str) -> list[dict]:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
f"/events?singleEvents=true&orderBy=startTime"
f"&timeMin={time_min}&timeMax={time_max}")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
out = []
for item in resp.json().get("items", []):
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
start, item.get("description", ""),
item.get("updated", "")))
return out
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
remote_id: str = "") -> str:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
body = {"summary": event.get("title", ""),
"description": event.get("description", ""),
"start": {"date": event.get("start", "")[:10]},
"end": {"date": event.get("start", "")[:10]}}
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
async with httpx.AsyncClient(timeout=15) as client:
if remote_id:
resp = await client.patch(f"{base}/{remote_id}",
headers={"Authorization": f"Bearer {access}"}, json=body)
else:
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
json=body)
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
return str(resp.json().get("id", remote_id or ""))
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop><D:getetag/><C:calendar-data/></D:prop>
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter></C:comp-filter></C:filter>
</C:calendar-query>"""
def _parse_caldav_events(xml_text: str) -> list[dict]:
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
import re
import xml.etree.ElementTree as ET
events = []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return []
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
for resp in root.findall("D:response", ns):
href = resp.findtext("D:href", default="", namespaces=ns)
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
if data_el is None or not data_el.text:
continue
ics = data_el.text
uid = re.search(r"^UID:(.+)$", ics, re.M)
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
events.append(_remote_event(
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
summary.group(1).strip() if summary else "Untitled",
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
desc.group(1).strip() if desc else ""))
return events
def _ics_to_date(value: str) -> str:
value = value.strip()
if len(value) >= 8 and value[:8].isdigit():
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
return value[:10]
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
day = (date or "")[:10].replace("-", "")
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
url = creds.get("url", "")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
body = _CALDAV_REPORT.format(
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.request("REPORT", url, content=body,
headers={"Depth": "1",
"Content-Type": "application/xml"})
if resp.status_code == 401:
raise SyncError("caldav rejected credentials")
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return _parse_caldav_events(resp.text)
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
url = (creds.get("url", "") or "").rstrip("/")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
href = f"{url}/{uid}.ics" if not remote_id else (
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
event.get("start", ""), event.get("description", ""))
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return uid
# ── mapping + sync ─────────────────────────────────────────────────────────
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
props = conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()
if wanted:
for p in props:
if str(p["id"]) == str(wanted) or p["name"] == wanted:
return str(p["id"]), p["name"]
return None
for p in props:
# prop_type lives in the row; fetch full rows only when needed
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
(p["id"],)).fetchone()
if full and full["prop_type"] == "date":
return str(p["id"]), p["name"]
return None
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
raw = values.get(prop_id, values.get(prop_name, ""))
if isinstance(raw, dict):
raw = raw.get("date") or raw.get("value") or ""
return str(raw or "")
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
text = str(value).strip()
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()
except Exception: # noqa: BLE001
try:
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
except Exception: # noqa: BLE001
return 0.0
def _window() -> tuple[str, str]:
now = datetime.now(UTC)
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
return start, end
async def sync_link(link_id: int) -> dict:
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
link = _load_link(link_id)
if not link:
raise ValueError("link not found")
creds = _decrypt_tokens(link.get("tokens_enc") or "")
collection_id = link.get("collection_id")
if not collection_id:
raise ValueError("link has no collection")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
tmin, tmax = _window()
if link["provider"] == "google":
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
tmin, tmax)
else:
remote = await caldav_list_events(creds, tmin, tmax)
last_sync = _to_epoch(link.get("last_sync"))
pulled = pushed = conflicts = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, property_values_json, updated_at,"
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchall()
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
seen_remote: set[str] = set()
touched: set[int] = set() # rows written by this pull pass — never push back
for ev in remote:
eid = ev.get("id", "")
if not eid:
continue
seen_remote.add(eid)
day = (ev.get("start") or "")[:10]
if eid not in local:
values: dict = {}
values[prop_id] = day
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, position, property_values_json, external_event_id)
VALUES (?,?,?,?,?)""",
(collection_id, ev.get("title") or "Untitled", max_pos,
json.dumps(values), eid))
pulled += 1
continue
row = local[eid]
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
local_day = _row_date(values, prop_id, prop_name)[:10]
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
if remote_newer and local_dirty and local_day and day and local_day != day:
# Conflict: both sides moved → last-write-wins + notify.
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
conflicts += 1
_notify_conflict(conn, link, row, ev)
elif day and day != local_day:
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
pulled += 1
# Push local changes (created locally or edited after last_sync).
for xid, row in local.items():
if row["id"] in touched:
continue
if xid in seen_remote:
# Edited locally since last sync and remote unchanged → push.
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
await _push(link, creds, row, prop_id, prop_name, xid)
pushed += 1
continue
# Remote deleted the event → drop the local id (keep the row).
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
(row["id"],))
# Rows never linked and recently touched → create remotely.
fresh = conn.execute(
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
(collection_id,)).fetchall()
for row in fresh:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
day = _row_date(values, prop_id, prop_name)[:10]
if not day:
continue
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
(new_id, row["id"]))
pushed += 1
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
(link_id,))
conn.commit()
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
prop_name: str, remote_id: str) -> str:
try:
values = json.loads(row.get("property_values_json") or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
event = {"title": row.get("title") or "Untitled",
"start": _row_date(values, prop_id, prop_name),
"description": ""}
if link["provider"] == "google":
return await google_push_event(creds, link.get("calendar_id") or "primary",
event, remote_id)
return await caldav_push_event(creds, event, remote_id)
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
try:
from app.services.notifications import create_notification
create_notification(
link["user_id"], link["user_id"], "calendar",
"Calendar sync conflict",
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
resource_type="collection", resource_id=link.get("collection_id") or 0,
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
except Exception: # noqa: BLE001 — notify must never break sync
pass
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
"""Background loop: sync every link with a collection (15 min default)."""
while True:
try:
with get_conn() as conn:
ids = [r["id"] for r in conn.execute(
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
).fetchall()]
for link_id in ids:
try:
await sync_link(link_id)
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
logger.debug("calendar sync link %s failed: %s", link_id, exc)
except Exception as exc: # noqa: BLE001
logger.warning("calendar_sync_scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── free/busy ──────────────────────────────────────────────────────────────
def freebusy(collection_id: int, date_from: str, date_to: str,
date_property: str = "") -> dict:
"""Busy/free weekdays in [date_from, date_to] (day granularity).
Expands recurrence rules server-side (``recurrence.expand_rule``).
"""
from app.services import recurrence as _rec
try:
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
except ValueError:
raise ValueError("use YYYY-MM-DD dates") from None
if end < start or (end - start).days > 370:
raise ValueError("range must be 1..370 days")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, date_property)
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
busy: set[str] = set()
for r in rows:
try:
values = json.loads(r["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
continue
base = _row_date(values, prop_id, prop_name)
if not base:
continue
rec = (values.get("__recurrence__") or {})
rule = rec.get(prop_id) or rec.get(prop_name)
if rule:
try:
for occ in _rec.expand_rule(
base, rule, start.isoformat(), end.isoformat()):
busy.add(occ[:10])
except Exception: # noqa: BLE001 — bad rule, use base date only
busy.add(base[:10])
else:
if start.isoformat() <= base[:10] <= end.isoformat():
busy.add(base[:10])
days, free = [], []
day = start
while day <= end:
iso = day.isoformat()
days.append({"date": iso, "busy": iso in busy,
"weekend": day.weekday() >= 5})
if iso not in busy and day.weekday() < 5:
free.append(iso)
day += timedelta(days=1)
return {"collection_id": collection_id, "from": start.isoformat(),
"to": end.isoformat(), "days": days, "free_weekdays": free}
+4
View File
@@ -639,6 +639,10 @@ def blocks_to_html(blocks: list) -> str:
icon = b.get("icon") or "💡"
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
elif t in ("mermaid", "equation_inline", "progress"):
# v7.3.0 blocks — server-rendered so export embeds real content
from app.services.wiki_blocks import render_block
parts.append(render_block(b))
elif t == "image":
src = b.get("src") or ""
alt = _text(b.get("alt"))
+27
View File
@@ -73,6 +73,33 @@ class GiteaClient:
self._set_cache(cache_key, data)
return data
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata for an unfurl card (owner/name/branch/…)."""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name") or f"{owner}/{repo}",
"clone_url": info.get("clone_url", ""),
"html_url": info.get("html_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
}
self._set_cache(cache_key, repo_info)
return repo_info
async def get_user_orgs(self) -> list[dict]:
cache_key = "user_orgs"
cached = self._cached(cache_key)
+1 -1
View File
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
logger.exception("get_repo_info")
self._set_cache(cache_key, repo_info)
return repo_info
+112
View File
@@ -0,0 +1,112 @@
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
``transcript`` (client-side transcription). Nothing here requires new pip
dependencies. See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
from app.db import get_conn
logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
MAX_AUDIO_BYTES = 100 * 1024 * 1024
class TranscriptionUnavailable(RuntimeError):
"""Raised when no transcription backend is configured."""
def meetings_dir() -> Path:
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True)
return d
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
"""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
"no transcription backend (set STT_COMMAND or POST a manual transcript)")
if shutil.which(cmd_template.split()[0]) is None:
raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}")
try:
proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured
capture_output=True, text=True, timeout=600)
except subprocess.TimeoutExpired as exc:
raise TranscriptionUnavailable("transcription timed out") from exc
text = (proc.stdout or "").strip()
if proc.returncode != 0 or not text:
raise TranscriptionUnavailable(
f"transcription failed: {(proc.stderr or '')[:300]}")
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
Returns {transcript_id, summary, offline}. Emits the automation event so
custom agents (update tracker, post recap, file tickets) trigger.
"""
from app.services.ai_writing import AIWritingService
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
raise ValueError("transcript is empty — transcribe first")
svc = AIWritingService(user_id=user_id)
res = await svc.run("summarize", context=tr["transcript"])
summary = (res.get("text") or "").strip() if res.get("ok") else ""
if not summary:
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
(summary, transcript_id))
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
+33 -3
View File
@@ -103,6 +103,34 @@ def parse_og(body: str, url: str) -> dict:
}
_MAX_REDIRECTS = 5
async def _get_checked(client, url: str, headers: dict):
"""GET avec re-vérification de l'hôte à CHAQUE saut de redirection (A12 SSRF).
`follow_redirects=True` laisserait une URL publique rediriger vers
169.254.169.254 / localhost — la garde doit donc tourner à chaque hop.
"""
from app.services.importers.url_fetch import _is_public_host
current = url
for _ in range(_MAX_REDIRECTS + 1):
parsed = urlparse(current)
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
raise ValueError(f"hôte non autorisé: {parsed.hostname!r}")
r = await client.get(current, headers=headers, follow_redirects=False)
if r.status_code in (301, 302, 303, 307, 308):
loc = r.headers.get("location")
if not loc:
return r
current = urljoin(current, loc)
continue
r.raise_for_status()
return r
raise ValueError("trop de redirections")
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors.
@@ -121,12 +149,14 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
kwargs = {"follow_redirects": True, "timeout": timeout}
kwargs = {"timeout": timeout}
if transport is not None:
kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client:
resp = await client.get(src, headers=headers)
resp.raise_for_status()
resp = await _get_checked(client, src, headers)
except ValueError:
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
raise
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
logger.debug("og fetch failed for %s: %s", src, exc)
base["title"] = urlparse(src).netloc or src
+68
View File
@@ -98,6 +98,74 @@ class PermissionManager:
).fetchone()
return "owner" if owner else "viewer"
# ── SSO (v6.7.0, design §7.2) ─────────────────────────────────────────
def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool:
"""True when that workspace can only be reached through SSO.
FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 /
§4.2): when it is on, local login is refused for every non-admin, so
every workspace on the instance is effectively SSO-only.
``workspace_id`` is accepted to mirror the design's per-workspace API.
"""
from app.services.sso_provisioning import is_sso_only
return is_sso_only()
def _user_auth_method(self) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT auth_method FROM users WHERE id=?", (self.user_id,)
).fetchone()
return (row["auth_method"] or "local") if row else "local"
def get_sso_roles(
self, user_id: int | None = None, workspace_id: int | None = None
) -> list[str]:
"""Roles granted to that user through SSO group mapping (design §7.2).
SSO grants land in the regular ``workspace_members`` row (the mapping
is re-applied at every SSO login), so the answer is the explicit
membership role of a non-local account — local accounts and users
without an explicit grant (the implicit *viewer* fallback is not an
SSO grant) get ``[]``.
"""
if workspace_id is None:
return []
pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self
if pm._user_auth_method() == "local":
return []
with get_conn() as conn:
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(int(workspace_id), pm.user_id),
).fetchone()
return [row["role"]] if row else []
def sync_sso_permissions(
self,
user_id: int | None,
sso_groups: list[str],
workspace_id: int | None = None,
) -> list[int]:
"""Re-apply the group → workspace role mapping (design §7.2).
Delegates to ``sso_provisioning.sync_sso_groups`` (the single source
of truth used at login and by ``POST /api/v2/sso/sync``). Returns the
touched workspace ids, narrowed to ``workspace_id`` when given.
"""
from app.services.sso_provisioning import get_sso_config, sync_sso_groups
cfg = get_sso_config()
if not cfg:
return []
touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg)
if workspace_id is not None:
touched = [w for w in touched if int(w) == int(workspace_id)]
if touched:
self.invalidate()
return touched
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES
+4
View File
@@ -96,6 +96,10 @@ PROPERTY_TYPES: dict[str, dict] = {
"storage": "auto — {id, login}",
"default": None,
},
"button": {
"storage": "none — runs linked automation (button_automation_id)",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
+1 -1
View File
@@ -349,7 +349,7 @@ class RealtimeManager:
try:
await conn.ws.close(code=4413)
except Exception:
pass
logger.exception("_evict_slow")
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
+535
View File
@@ -0,0 +1,535 @@
"""FlowDeck — semantic (vector) search + Ask AI (v6.9.0).
Hybrid retrieval = lexical (FTS5/LIKE via :mod:`app.services.search`) fused
with vector cosine similarity via Reciprocal Rank Fusion, then filtered
through :class:`PermissionManager` so unauthorized chunks never surface
(and never enter an LLM prompt).
Vectors use a dependency-free **hashed TF** encoder (``hash-256``): token →
``md5 % 256`` with L2 normalization. Deterministic, offline-first, good
enough for recall on small workspaces; the ``embed_texts`` entry point is
pluggable should an LLM ``/embeddings`` provider be wired later.
See ``docs/V69_Search_Ask_AI.md``.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import math
import re
import struct
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
DIM = 256
MODEL = "hash-256"
CHUNK_SIZE = 1200
CHUNK_OVERLAP = 150
MAX_CHUNKS_PER_RESOURCE = 50
RRF_K = 60
_TOKEN_RE = re.compile(r"[\wÀ-ÿ]+", flags=re.UNICODE)
# Ask cache: (question_hash, workspace_id, user_id) -> (expires_at, payload)
_ask_cache: dict[tuple[str, int | None, int], tuple[float, dict]] = {}
_ASK_CACHE_TTL = 600.0
# Ask rate limit: user_id -> (window_start, count)
_ask_rate: dict[int, tuple[float, int]] = {}
_ASK_RATE_MAX = 30
_ASK_RATE_WINDOW = 60.0
# ── text extraction & chunking ─────────────────────────────────────────────
def _blocks_to_text(blocks) -> list[str]:
parts: list[str] = []
def _walk(items) -> None:
for b in items or []:
if not isinstance(b, dict):
continue
for key in ("content", "text", "title"):
val = b.get(key)
if isinstance(val, str) and val.strip():
parts.append(val.strip())
break
children = b.get("children")
if isinstance(children, list):
_walk(children)
_walk(blocks if isinstance(blocks, list) else [])
return parts
def extract_page_text(content: str | None, content_format: str | None) -> str:
"""Full searchable text of a ``pages`` row (all blocks, recursive)."""
if not content:
return ""
if (content_format or "blocks") == "blocks":
try:
blocks = json.loads(content)
return "\n".join(_blocks_to_text(blocks))
except Exception:
return content
return content
def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
"""Split text into overlapping chunks (char-based, word-boundary aware)."""
text = (text or "").strip()
if not text:
return []
if len(text) <= size:
return [text]
chunks: list[str] = []
start = 0
while start < len(text):
end = min(start + size, len(text))
if end < len(text):
space = text.rfind(" ", start, end)
if space > start + size // 2:
end = space
chunks.append(text[start:end].strip())
if end >= len(text):
break
start = max(end - overlap, start + 1)
if len(chunks) >= MAX_CHUNKS_PER_RESOURCE:
break
return [c for c in chunks if c]
# ── hashed-TF embeddings ───────────────────────────────────────────────────
def _tokens(text: str) -> list[str]:
return [t.lower() for t in _TOKEN_RE.findall(text or "") if t]
def embed_text(text: str, dim: int = DIM) -> bytes:
"""Deterministic L2-normalized hashed-TF vector, struct-packed float32."""
vec = [0.0] * dim
for tok in _tokens(text):
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim
vec[idx] += 1.0
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
return struct.pack(f"<{dim}f", *vec)
def embed_texts(texts: list[str], dim: int = DIM) -> list[bytes]:
"""Batch entry point (pluggable: LLM /embeddings can replace hashing)."""
return [embed_text(t, dim) for t in texts]
def cosine(a: bytes, b: bytes, dim: int = DIM) -> float:
"""Cosine similarity of two packed normalized vectors (== dot product)."""
try:
va = struct.unpack(f"<{dim}f", a)
vb = struct.unpack(f"<{dim}f", b)
except struct.error:
return 0.0
return sum(x * y for x, y in zip(va, vb, strict=True))
# ── indexing ───────────────────────────────────────────────────────────────
def _resource_text(conn, resource_type: str, resource_id: int) -> str | None:
"""Return indexable text, or None when the resource must not be indexed."""
if resource_type == "page":
row = conn.execute(
"SELECT title, content, content_format FROM pages "
"WHERE id=? AND (deleted_at IS NULL OR deleted_at='') "
"AND COALESCE(search_excluded, 0)=0",
(resource_id,),
).fetchone()
if not row:
return None
body = extract_page_text(row["content"], row["content_format"])
return f"{row['title'] or ''}\n{body}".strip()
if resource_type == "collection":
row = conn.execute(
"SELECT name, description FROM collections WHERE id=?", (resource_id,)
).fetchone()
if not row:
return None
return f"{row['name'] or ''}\n{row['description'] or ''}".strip()
return None
def index_resource(resource_type: str, resource_id: int) -> int:
"""(Re)index one resource. Returns the number of chunks stored."""
with get_conn() as conn:
text = _resource_text(conn, resource_type, resource_id)
conn.execute(
"DELETE FROM semantic_embeddings WHERE resource_type=? AND resource_id=?",
(resource_type, resource_id),
)
n = 0
if text:
for i, chunk in enumerate(chunk_text(text)[:MAX_CHUNKS_PER_RESOURCE]):
conn.execute(
"""INSERT INTO semantic_embeddings
(resource_type, resource_id, chunk_id, chunk_text, embedding, model)
VALUES (?, ?, ?, ?, ?, ?)""",
(resource_type, resource_id, i, chunk, embed_text(chunk), MODEL),
)
n += 1
conn.execute(
"""INSERT INTO semantic_index_state (resource_type, resource_id, indexed_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(resource_type, resource_id)
DO UPDATE SET indexed_at=CURRENT_TIMESTAMP""",
(resource_type, resource_id),
)
conn.commit()
return n
def _stale_resources(conn, limit: int) -> list[tuple[str, int]]:
out: list[tuple[str, int]] = []
rows = conn.execute(
"""SELECT p.id, p.updated_at FROM pages p
LEFT JOIN semantic_index_state s
ON s.resource_type='page' AND s.resource_id=p.id
WHERE (p.deleted_at IS NULL OR p.deleted_at='')
AND COALESCE(p.search_excluded, 0)=0
AND (s.indexed_at IS NULL OR p.updated_at > s.indexed_at)
ORDER BY p.updated_at DESC LIMIT ?""",
(limit,),
).fetchall()
out += [("page", r["id"]) for r in rows]
if len(out) < limit:
rows = conn.execute(
"""SELECT c.id, c.updated_at FROM collections c
LEFT JOIN semantic_index_state s
ON s.resource_type='collection' AND s.resource_id=c.id
WHERE s.indexed_at IS NULL OR c.updated_at > s.indexed_at
ORDER BY c.updated_at DESC LIMIT ?""",
(limit - len(out),),
).fetchall()
out += [("collection", r["id"]) for r in rows]
return out
def purge_orphans() -> int:
"""Drop vectors for deleted/excluded resources. Returns rows removed."""
with get_conn() as conn:
cur = conn.execute(
"""DELETE FROM semantic_embeddings
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.execute(
"""DELETE FROM semantic_index_state
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.commit()
return cur.rowcount or 0
def index_pending(limit: int = 50) -> dict:
"""Index up to ``limit`` stale resources + purge orphans (scheduler job)."""
with get_conn() as conn:
stale = _stale_resources(conn, limit)
indexed = 0
for rtype, rid in stale:
try:
index_resource(rtype, rid)
indexed += 1
except Exception as exc: # never break the scheduler loop
logger.debug("semantic index failed for %s %s: %s", rtype, rid, exc)
purged = purge_orphans()
return {"checked": len(stale), "indexed": indexed, "purged": purged}
async def semantic_index_scheduler(interval_seconds: int = 300) -> None:
"""Background task: incremental indexing (wired in app lifespan)."""
while True:
try:
await asyncio.to_thread(index_pending)
except Exception as exc: # noqa: BLE001 — scheduler must survive
logger.debug("semantic index scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── vector search ──────────────────────────────────────────────────────────
def vector_search(query: str, *, limit: int = 20,
resource_types: tuple[str, ...] = ("page", "collection")) -> list[dict]:
"""Brute-force cosine scan (fine at this scale). Returns ranked chunks."""
q = (query or "").strip()
if not q:
return []
qvec = embed_text(q)
with get_conn() as conn:
placeholders = ",".join("?" for _ in resource_types)
rows = conn.execute(
f"""SELECT resource_type, resource_id, chunk_id, chunk_text
FROM semantic_embeddings WHERE resource_type IN ({placeholders})""",
list(resource_types),
).fetchall()
scored = []
for r in rows:
row = conn.execute(
"SELECT embedding FROM semantic_embeddings "
"WHERE resource_type=? AND resource_id=? AND chunk_id=?",
(r["resource_type"], r["resource_id"], r["chunk_id"]),
).fetchone()
s = cosine(qvec, row["embedding"]) if row else 0.0
if s > 0:
scored.append({
"resource_type": r["resource_type"],
"resource_id": r["resource_id"],
"chunk_id": r["chunk_id"],
"chunk_text": r["chunk_text"],
"score": s,
})
scored.sort(key=lambda d: d["score"], reverse=True)
return scored[:limit]
# ── hybrid (lexical + vector, RRF) + ACL ───────────────────────────────────
def _rrf_fuse(ranked_lists: list[list[tuple[str, int]]], k: int = RRF_K) -> list[tuple[str, int, float]]:
scores: dict[tuple[str, int], float] = {}
for ranked in ranked_lists:
for rank, key in enumerate(ranked):
scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1)
fused = [(t, i, s) for (t, i), s in scores.items()]
fused.sort(key=lambda x: x[2], reverse=True)
return fused
def hybrid_search(query: str, user: dict, *, limit: int = 20,
workspace_id: int | None = None,
resource_types: tuple[str, ...] = ("page", "collection")) -> tuple[list[dict], int]:
"""Lexical + vector fusion, workspace-scoped, ACL-filtered.
Returns (results, total). Each result: {type, id, title, excerpt, url, score}.
"""
from app.services.permission_manager import PermissionManager
q = (query or "").strip()
if not q:
return [], 0
limit = max(1, min(int(limit or 20), 100))
user_id = user.get("id")
pm = PermissionManager(user_id, bool(user.get("is_admin")))
# 1) lexical candidates (already workspace-membership scoped)
from app.services import search as search_service
lex = search_service.search(q, user_id, limit * 3)
lex_ranked: list[tuple[str, int]] = []
lex_by_key: dict[tuple[str, int], dict] = {}
for item in (lex.get("pages") or []) + (lex.get("collections") or []):
key = (item["type"], item["id"])
if key not in lex_by_key:
lex_by_key[key] = item
lex_ranked.append(key)
# 2) vector candidates
vec = vector_search(q, limit=limit * 3, resource_types=resource_types)
vec_ranked = [(d["resource_type"], d["resource_id"]) for d in vec]
# 3) fuse
fused = _rrf_fuse([lex_ranked, vec_ranked])
# 4) ACL + workspace filter, enrich
results: list[dict] = []
with get_conn() as conn:
for rtype, rid, score in fused:
if rtype == "page":
if not pm.can_view_page(rid):
continue
row = conn.execute(
"SELECT id, title, content, content_format, workspace_id, "
"COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (rid,)).fetchone()
if not row or row["excluded"]:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("excerpt")
or extract_page_text(row["content"], row["content_format"])[:160])
results.append({"type": "page", "id": rid,
"title": (row["title"] or "Untitled"),
"excerpt": excerpt, "url": f"/pages/{rid}",
"score": round(score, 5)})
else:
if not pm.can_view_collection(rid):
continue
row = conn.execute(
"SELECT id, name, description, workspace_id FROM collections WHERE id=?",
(rid,)).fetchone()
if not row:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("subtitle")
or (row["description"] or "")[:160])
results.append({"type": "collection", "id": rid,
"title": (row["name"] or "Untitled"),
"excerpt": excerpt, "url": f"/db/{rid}",
"score": round(score, 5)})
if len(results) >= limit:
break
return results, len(results)
# ── Ask AI ─────────────────────────────────────────────────────────────────
def _check_ask_rate(user_id: int) -> None:
from fastapi import HTTPException
now = time.time()
start, count = _ask_rate.get(user_id, (now, 0))
if now - start > _ASK_RATE_WINDOW:
_ask_rate[user_id] = (now, 1)
return
if count >= _ASK_RATE_MAX:
raise HTTPException(429, "Too many questions. Slow down.")
_ask_rate[user_id] = (start, count + 1)
def _offline_answer(question: str, chunks: list[dict]) -> str:
"""Extractive fallback: top sentences sharing query terms + citations."""
qterms = {t.lower() for t in _tokens(question)}
picked: list[str] = []
for ch in chunks[:8]:
for sent in re.split(r"(?<=[.!?])\s+", ch["chunk_text"] or ""):
words = {t.lower() for t in _tokens(sent)}
if qterms & words and len(sent.strip()) > 20:
picked.append((sent.strip(), ch))
if len(picked) >= 4:
break
if len(picked) >= 4:
break
if not picked:
# No lexical overlap: still cite the top vector matches.
lines = []
for ch in chunks[:3]:
snippet = (ch["chunk_text"] or "")[:200].replace("\n", " ")
lines.append(f"- {snippet} [[fdpage:{ch['resource_id']}]]"
if ch["resource_type"] == "page" else f"- {snippet}")
return ("Je n'ai pas trouvé de passage répondant directement, "
"mais voici les passages les plus proches :\n" + "\n".join(lines))
lines = []
for sent, ch in picked:
if ch["resource_type"] == "page":
lines.append(f"- {sent} [[fdpage:{ch['resource_id']}]]")
else:
lines.append(f"- {sent}")
return "Voici ce que j'ai trouvé dans votre workspace :\n" + "\n".join(lines)
def _resolve_citations(conn, chunks: list[dict]) -> list[dict]:
seen: list[dict] = []
done: set[tuple[str, int]] = set()
for ch in chunks:
key = (ch["resource_type"], ch["resource_id"])
if key in done:
continue
done.add(key)
if ch["resource_type"] == "page":
row = conn.execute("SELECT title FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
seen.append({"type": "page", "id": ch["resource_id"],
"title": (row["title"] if row else "Deleted page") or "Untitled"})
else:
row = conn.execute("SELECT name FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
seen.append({"type": "collection", "id": ch["resource_id"],
"title": (row["name"] if row else "Deleted") or "Untitled"})
return seen
async def ask(question: str, user: dict, workspace_id: int | None = None) -> dict:
"""RAG answer over the user's authorized chunks (LLM or offline fallback)."""
from app.services.permission_manager import PermissionManager
q = (question or "").strip()
if not q:
from fastapi import HTTPException
raise HTTPException(400, "question is required")
_check_ask_rate(user.get("id") or 0)
cache_key = (hashlib.sha256(q.encode()).hexdigest(), workspace_id, user.get("id"))
now = time.time()
hit = _ask_cache.get(cache_key)
if hit and hit[0] > now:
out = dict(hit[1])
out["cached"] = True
return out
pm = PermissionManager(user.get("id"), bool(user.get("is_admin")))
vec = vector_search(q, limit=24)
allowed = []
with get_conn() as conn:
for ch in vec:
if ch["resource_type"] == "page":
row = conn.execute(
"SELECT workspace_id, COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
if not row or row["excluded"] or not pm.can_view_page(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
else:
row = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
if not row or not pm.can_view_collection(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
allowed.append(ch)
if len(allowed) >= 8:
break
answer = ""
offline = True
if allowed:
try:
from app.services.llm_client import LLMClient
llm = LLMClient()
if await llm.is_available():
ctx = "\n\n".join(
f"[doc {i+1} page_id={c['resource_id']}]\n{c['chunk_text'][:1500]}"
for i, c in enumerate(allowed)
)
resp = await llm.complete([
{"role": "system",
"content": "Réponds en français en citant les sources avec "
"[[fdpage:ID]] (ID = page_id indiqué). Concis."},
{"role": "user", "content": f"Question : {q}\n\nContexte :\n{ctx}"},
])
answer = (resp.text or "").strip()
offline = False
except Exception as exc: # noqa: BLE001 — fall back to extractive
logger.debug("ask LLM failed, offline fallback: %s", exc)
if not answer:
answer = ("Aucun contenu accessible ne correspond à votre question."
if not allowed else _offline_answer(q, allowed))
with get_conn() as conn:
citations = _resolve_citations(conn, allowed[:8])
out = {"answer_markdown": answer, "citations": citations,
"offline": offline, "cached": False}
_ask_cache[cache_key] = (now + _ASK_CACHE_TTL, out)
return out
def reset_state() -> None:
"""Test helper: clear ask cache + rate limiter."""
_ask_cache.clear()
_ask_rate.clear()
+783
View File
@@ -0,0 +1,783 @@
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
Single source of truth for the SSO configuration (``sso_config`` table, with
an ``SSO_*`` environment fallback for bootstrap installs) and for what
happens when an IdP says "this is [email protected]":
1. resolve the local account (by email → merge, else by login),
2. create it when ``auto_provision`` is on, else reject with an audit row,
3. sync attributes + map SSO groups to workspace roles,
4. hand back the user dict so the caller can mint a session.
Secrets at rest: ``client_secret`` and the generated SP private key are
encrypted with a Fernet key derived from ``app_secret_key``.
"""
from __future__ import annotations
import hashlib
import json
import logging
import secrets
import time
import urllib.parse
from app.config import settings
logger = logging.getLogger(__name__)
VALID_PROVIDER_TYPES = ("saml", "oidc")
class SSOConfigError(Exception):
"""Invalid SSO configuration payload (message shown to the admin)."""
class SSOProvisioningError(Exception):
"""A login was rejected (no local account, missing attributes…)."""
# ── Secrets at rest ────────────────────────────────────────────────────────
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
import base64
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_secret(value: str) -> str:
if not value:
return ""
return _fernet().encrypt(value.encode()).decode()
def decrypt_secret(value: str) -> str:
if not value:
return ""
try:
return _fernet().decrypt(value.encode()).decode()
except Exception:
return "" # key rotated / not ours — treat as unset
# ── Config store ───────────────────────────────────────────────────────────
def _default_mapping(provider_type: str) -> dict:
if provider_type == "oidc":
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
return dict(DEFAULT_OIDC_MAPPING)
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
return dict(DEFAULT_SAML_MAPPING)
def _env_config() -> dict | None:
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
Only used when the table holds no active row — the Settings UI always
wins once an admin saved a configuration.
"""
provider_type = (settings.sso_provider or "").strip().lower()
if provider_type not in VALID_PROVIDER_TYPES:
return None
cfg = {
"id": 0,
"provider_type": provider_type,
"name": settings.sso_name or "Company SSO",
"entity_id": settings.sso_entity_id,
"sso_url": settings.sso_sso_url,
"slo_url": settings.sso_slo_url,
"x509_certificate": settings.sso_x509_certificate,
"issuer_url": settings.sso_issuer_url,
"client_id": settings.sso_client_id,
"client_secret": settings.sso_client_secret,
"scope": settings.sso_scope,
"attribute_mapping": settings.sso_attribute_mapping,
"groups_mapping": settings.sso_groups_mapping,
"auto_provision": int(settings.sso_auto_provision),
"sso_only": int(settings.sso_only),
"sign_requests": int(settings.sso_sign_requests),
"default_workspace_id": settings.sso_default_workspace_id,
"sp_private_key": "",
"sp_certificate": "",
"workspace_id": None,
"active": 1,
"_source": "env",
}
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
return None
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
return None
return cfg
def get_sso_config(require_active: bool = True) -> dict | None:
"""Active SSO config as a dict (DB row, else env fallback)."""
row = _raw_row(require_active=require_active)
if row:
cfg = dict(row)
cfg["_source"] = "db"
return cfg
if not require_active:
return _env_config()
return _env_config()
def _raw_row(require_active: bool = True) -> dict | None:
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
from app.db import get_conn
try:
with get_conn() as conn:
where = "WHERE active=1" if require_active else ""
row = conn.execute(
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
).fetchone()
except Exception: # table missing (very old install) → env only
return None
return dict(row) if row else None
def client_secret_value(cfg: dict) -> str:
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
raw = (cfg or {}).get("client_secret") or ""
if not raw:
return ""
if (cfg or {}).get("_source") == "env":
return raw
return decrypt_secret(raw)
def _json_field(value, fallback):
if isinstance(value, (dict, list)):
return value
try:
parsed = json.loads(value or "")
return parsed if isinstance(parsed, type(fallback)) else fallback
except Exception:
return fallback
def _strict_json(value, expected, field: str):
"""Parse a payload field and reject wrong shapes (before normalize,
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
if value is None or value == "":
return expected()
if isinstance(value, (dict, list)):
parsed = value
else:
try:
parsed = json.loads(value)
except Exception as exc:
raise SSOConfigError(f"{field} must be valid JSON") from exc
if not isinstance(parsed, expected):
kind = "object" if expected is dict else "array"
raise SSOConfigError(f"{field} must be a JSON {kind}")
return parsed
def normalize_config(cfg: dict) -> dict:
"""Parse JSON columns + fill defaults (single place for every consumer)."""
out = dict(cfg)
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
if not out["attribute_mapping"]:
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
"client_id", "client_secret", "scope", "name"):
out[key] = (out.get(key) or "").strip()
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
out[key] = int(out.get(key) or 0)
return out
def validate_config_payload(payload: dict) -> dict:
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
provider_type = str(payload.get("provider_type") or "").strip().lower()
if provider_type not in VALID_PROVIDER_TYPES:
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
payload = dict(payload)
payload["attribute_mapping"] = _strict_json(
payload.get("attribute_mapping"), dict, "attribute_mapping"
)
payload["groups_mapping"] = _strict_json(
payload.get("groups_mapping"), list, "groups_mapping"
)
cfg = normalize_config({**payload, "provider_type": provider_type})
if provider_type == "saml":
for field in ("entity_id", "sso_url"):
if not cfg[field]:
raise SSOConfigError(f"SAML requires '{field}'")
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
if url and not url.startswith(("http://", "https://")):
raise SSOConfigError(f"'{field}' must be an http(s) URL")
cert = cfg["x509_certificate"].strip()
if cert and "BEGIN CERTIFICATE" not in cert:
raise SSOConfigError("x509_certificate must be a PEM certificate")
if not cert:
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
cfg["x509_certificate"] = cert
else:
if not cfg["issuer_url"] or not cfg["client_id"]:
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
if not cfg["issuer_url"].startswith(("http://", "https://")):
raise SSOConfigError("'issuer_url' must be an http(s) URL")
if not isinstance(cfg["attribute_mapping"], dict):
raise SSOConfigError("attribute_mapping must be a JSON object")
if not isinstance(cfg["groups_mapping"], list):
raise SSOConfigError("groups_mapping must be a JSON array")
for entry in cfg["groups_mapping"]:
if not isinstance(entry, dict) or "sso_group" not in entry:
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
ws = cfg.get("default_workspace_id")
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
return cfg
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
"""Create or replace the single SSO configuration (idempotent)."""
from app.db import get_conn
cfg = validate_config_payload(payload)
columns = {
"provider_type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"entity_id": cfg["entity_id"],
"sso_url": cfg["sso_url"],
"slo_url": cfg["slo_url"],
"x509_certificate": cfg["x509_certificate"],
"issuer_url": cfg["issuer_url"],
"client_id": cfg["client_id"],
"scope": cfg.get("scope") or "openid profile email",
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
"groups_mapping": json.dumps(cfg["groups_mapping"]),
"auto_provision": cfg["auto_provision"],
"sso_only": cfg["sso_only"],
"sign_requests": cfg["sign_requests"],
"default_workspace_id": cfg["default_workspace_id"],
"active": 1,
"updated_at": str(int(time.time())),
}
# Secret handling: a blank incoming secret keeps the stored one (the raw
# row still holds the Fernet blob — never re-encrypt a decrypted value).
existing = _raw_row() or {}
if "client_secret" in cfg:
incoming = str(cfg.get("client_secret") or "").strip()
if incoming:
columns["client_secret"] = encrypt_secret(incoming)
else:
columns["client_secret"] = existing.get("client_secret") or ""
# SP keypair: keep an existing one, generate one for SAML if missing.
sp_key = existing.get("sp_private_key") or ""
sp_cert = existing.get("sp_certificate") or ""
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
sp_key, sp_cert = generate_sp_keypair()
columns["sp_private_key"] = sp_key
columns["sp_certificate"] = sp_cert
if created_by:
columns["created_by"] = created_by
with get_conn() as conn:
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
if row:
sets = ", ".join(f"{k}=?" for k in columns)
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
cfg_id = row["id"]
else:
keys = ", ".join(columns)
placeholders = ", ".join("?" for _ in columns)
cur = conn.execute(
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
)
cfg_id = cur.lastrowid
conn.commit()
saved = get_sso_config(require_active=False)
saved["id"] = cfg_id
return saved
def delete_sso_config() -> bool:
"""Disable SSO entirely (local logins keep working)."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
conn.commit()
return cur.rowcount > 0
def public_config_view(cfg: dict | None) -> dict:
"""Config for the admin UI — secrets never leave the server."""
if not cfg:
return {"configured": False}
cfg = normalize_config(cfg)
return {
"configured": True,
"id": cfg.get("id"),
"source": cfg.get("_source", "db"),
"provider_type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"entity_id": cfg["entity_id"],
"sso_url": cfg["sso_url"],
"slo_url": cfg["slo_url"],
"x509_certificate": cfg["x509_certificate"],
"issuer_url": cfg["issuer_url"],
"client_id": cfg["client_id"],
"client_secret_set": bool(client_secret_value(cfg)),
"scope": cfg.get("scope") or "openid profile email",
"attribute_mapping": cfg["attribute_mapping"],
"groups_mapping": cfg["groups_mapping"],
"auto_provision": bool(cfg["auto_provision"]),
"sso_only": bool(cfg["sso_only"]),
"sign_requests": bool(cfg["sign_requests"]),
"default_workspace_id": cfg.get("default_workspace_id"),
"sp_certificate": cfg.get("sp_certificate") or "",
"active": bool(cfg.get("active", 1)),
"provisioned_users": provisioned_count(),
}
def is_sso_only(cfg: dict | None = None) -> bool:
"""True when local login must be refused (design §7.1 / §4.2)."""
cfg = cfg if cfg is not None else get_sso_config()
return bool(cfg and normalize_config(cfg).get("sso_only"))
def provisioned_count() -> int:
from app.db import get_conn
try:
with get_conn() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
).fetchone()
return int(row["n"] if row is not None else 0)
except Exception:
return 0
def generate_sp_keypair() -> tuple[str, str]:
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
])
now = datetime.datetime.now(datetime.UTC)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
priv = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public = cert.public_bytes(serialization.Encoding.PEM).decode()
return priv, public
def ensure_sp_keypair(cfg: dict) -> dict:
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
if cfg.get("provider_type") != "saml":
return cfg
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
return cfg
from app.db import get_conn
priv, cert = generate_sp_keypair()
try:
with get_conn() as conn:
conn.execute(
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
(priv, cert, cfg.get("id")),
)
conn.commit()
except Exception as err: # env-sourced config has no row to update
logger.debug("SP keypair not persisted: %s", err)
cfg = dict(cfg)
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
return cfg
cfg = dict(cfg)
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
return cfg
# ── Audit ──────────────────────────────────────────────────────────────────
def log_sso_login(
*,
user_id: int | None,
provider_type: str,
provider_name: str,
identifier: str,
request,
success: bool,
error: str = "",
) -> None:
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"""INSERT INTO sso_login_history
(user_id, provider_type, provider_name, sso_identifier,
ip_address, user_agent, success, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
1 if success else 0, (error or "")[:500]),
)
conn.commit()
except Exception as err: # audit must never break the login path
logger.warning("sso_login_history write failed: %s", err)
# ── Group mapping ──────────────────────────────────────────────────────────
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
from app.db import get_conn
cfg = normalize_config(cfg)
mappings = cfg.get("groups_mapping") or []
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
touched: list[int] = []
with get_conn() as conn:
for entry in mappings:
group_name = str(entry.get("sso_group") or "").strip().lower()
if not group_name or group_name not in wanted:
continue
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
if not ws_id:
continue
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
if role not in ("owner", "admin", "editor", "viewer"):
role = "editor"
conn.execute(
"""INSERT INTO workspace_members (workspace_id, user_id, role)
VALUES (?, ?, ?)
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
(int(ws_id), user_id, role),
)
touched.append(int(ws_id))
# Default workspace: every SSO user lands there as a plain member.
default_ws = cfg.get("default_workspace_id")
if default_ws:
conn.execute(
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
VALUES (?, ?, 'editor')""",
(int(default_ws), user_id),
)
if int(default_ws) not in touched:
touched.append(int(default_ws))
conn.commit()
return touched
def force_sync_all_groups() -> dict:
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
from app.db import get_conn
cfg = get_sso_config()
if not cfg:
raise SSOProvisioningError("No SSO configuration")
cfg = normalize_config(cfg)
mapping = cfg.get("attribute_mapping") or {}
groups_source = mapping.get("groups", "groups")
updated = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
).fetchall()
for row in rows:
groups = _stored_groups(row["id"], groups_source, cfg)
if sync_sso_groups(row["id"], groups, cfg):
updated += 1
return {"users": len(rows), "updated": updated}
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
"""Groups seen at the last login of that user (stored in attribute sync)."""
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT sso_identifier FROM sso_login_history "
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
(user_id,),
).fetchone()
if not row or not row["sso_identifier"]:
return []
raw = row["sso_identifier"]
if "|" in raw:
ident, _, groups_json = raw.partition("|")
groups = json.loads(groups_json or "[]")
return [str(g) for g in groups] if isinstance(groups, list) else []
return []
except Exception:
return []
# ── Auto-provisioning ──────────────────────────────────────────────────────
def _unique_login(conn, base: str) -> str:
candidate = base
n = 1
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
n += 1
candidate = f"{base}_{n}"
return candidate
def identity_from_saml(identity, cfg: dict) -> dict:
"""Apply the SAML attribute mapping to a validated assertion."""
cfg = normalize_config(cfg)
mapping = cfg.get("attribute_mapping") or {}
out = {
"login": identity.resolve(mapping.get("login", "nameid")),
"email": identity.resolve(mapping.get("email", "nameid")),
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
"name_id": identity.name_id,
}
groups = identity.resolve(mapping.get("groups", "groups"))
if groups:
# Multi-valued SAML attribute: take every value of the resolved source.
source = mapping.get("groups", "groups")
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
else:
out["groups"] = []
out["email"] = (out["email"] or "").strip().lower()
if "@" not in out["email"]:
# NameID may be a persistent opaque id — fall back to login when it is
# an email, otherwise leave empty (login will carry the identity).
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
if not out["full_name"]:
out["full_name"] = out["email"] or out["login"]
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
return out
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
"""Resolve/create the local user for an SSO identity. Returns the user dict.
Raises ``SSOProvisioningError`` when the login must be refused (the
caller writes the audit row).
"""
from app.db import get_conn
cfg = normalize_config(cfg)
email = (identity.get("email") or "").strip().lower()
login_hint = (identity.get("login") or "").strip()
if not email and not login_hint:
raise SSOProvisioningError(
"SSO assertion carries no usable email/login — check the attribute mapping"
)
with get_conn() as conn:
user = None
if email:
user = conn.execute(
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
(email,),
).fetchone()
if not user and login_hint:
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
if user:
# §7.1 — email match → merge: the existing account is reused and
# tagged with the SSO method (no duplicate account).
updates, params = [], []
if identity.get("full_name"):
updates.append("full_name=?")
params.append(identity["full_name"])
if email:
updates.append("email=?")
params.append(email)
if identity.get("avatar_url"):
updates.append("avatar_url=?")
params.append(identity["avatar_url"])
updates.append("auth_method=?")
params.append(provider_type)
updates.append("last_login=?")
params.append(str(time.time()))
params.append(user["id"])
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
else:
if not cfg.get("auto_provision"):
raise SSOProvisioningError(
"No local account for this SSO identity and auto-provisioning is disabled"
)
base_login = login_hint or email
login = _unique_login(conn, base_login)
conn.execute(
"""INSERT INTO users
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, 0, ?)""",
(
login,
identity.get("full_name") or email or login,
email,
identity.get("avatar_url") or "",
provider_type,
str(time.time()),
),
)
conn.commit()
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not row:
raise SSOProvisioningError("Could not create or load the SSO user")
user_dict = dict(row)
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
return user_dict
def sso_identifier_field(identity: dict) -> str:
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
groups = identity.get("groups") or []
if groups:
return f"{ident}|{json.dumps(groups)}"
return ident
def safe_next_path(candidate: str | None) -> str:
"""Sanitize the post-login redirect target (open-redirect guard)."""
if not candidate:
return "/workspaces"
candidate = str(candidate)
if not candidate.startswith("/") or candidate.startswith("//"):
return "/workspaces"
parsed = urllib.parse.urlsplit(candidate)
if parsed.scheme or parsed.netloc:
return "/workspaces"
return candidate
# ── Anti-replay request store ──────────────────────────────────────────────
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
def create_request(kind: str, *, request_id: str, relay_state: str = "",
code_verifier: str = "", next_path: str = "/workspaces") -> None:
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
from app.db import get_conn
purge_stale_requests()
with get_conn() as conn:
conn.execute(
"""INSERT OR REPLACE INTO sso_requests
(id, kind, relay_state, code_verifier, next_path, used, created_at)
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
)
conn.commit()
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
if not request_id:
return None
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
(f"-{REQUEST_TTL_SECONDS} seconds",),
)
row = conn.execute(
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
(request_id, kind),
).fetchone()
if not row:
return None
if relay_state and row["relay_state"] and not secrets.compare_digest(
row["relay_state"], relay_state
):
return None
cur = conn.execute(
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
)
conn.commit()
if cur.rowcount != 1:
return None
return dict(row)
def peek_request(kind: str, request_id: str) -> dict | None:
"""Read a request without consuming it (CSRF check before heavy validation)."""
if not request_id:
return None
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
(request_id, kind),
).fetchone()
return dict(row) if row else None
def was_consumed(kind: str, request_id: str) -> bool:
"""True when this single-use request id was already spent (replay)."""
if not request_id:
return False
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
(request_id, kind),
).fetchone()
return row is not None
def purge_stale_requests() -> None:
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
(f"-{REQUEST_TTL_SECONDS} seconds",),
)
conn.commit()
except Exception:
logger.exception("purge_stale_requests")
+2 -1
View File
@@ -17,6 +17,7 @@ import json
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid))
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.db import get_conn
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
+136
View File
@@ -0,0 +1,136 @@
"""FlowDeck — TOTP 2FA + backup codes (v7.2.0).
Secrets are Fernet-encrypted at rest (same construction as SSO secrets).
Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived
signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a
session. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import hashlib
import json
import secrets
from app.db import get_conn
BACKUP_CODE_COUNT = 10
def _fernet():
import base64
from cryptography.fernet import Fernet
from app.config import settings
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def is_enabled(user_id: int) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode())
except Exception: # noqa: BLE001
return False
def setup_secret(user_id: int) -> dict:
"""Create a new TOTP secret (not yet active until verified)."""
import pyotp
secret = pyotp.random_base32()
with get_conn() as conn:
row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone()
label = (row["email"] or row["login"]) if row else f"user{user_id}"
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck")
return {"secret": secret, "otpauth_url": uri}
def activate_secret(user_id: int, secret: str, code: str) -> list[str]:
"""Verify ``code`` against ``secret``; on success store + return backup codes."""
import pyotp
if not pyotp.TOTP(secret).verify(code, valid_window=1):
raise ValueError("invalid code")
codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)]
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?",
(_fernet().encrypt(secret.encode()).decode(),
json.dumps(hashes), user_id))
conn.commit()
return codes
def verify_code(user_id: int, code: str) -> bool:
"""Check a TOTP code or consume a backup code."""
code = (code or "").strip().replace(" ", "")
if not code:
return False
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode()
except Exception: # noqa: BLE001
return False
import pyotp
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
return True
# backup codes (single use)
try:
hashes = json.loads(row["totp_backup_hashes"] or "[]")
except (TypeError, json.JSONDecodeError):
hashes = []
digest = hashlib.sha256(code.encode()).hexdigest()
if digest in hashes:
hashes.remove(digest)
with get_conn() as conn:
conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?",
(json.dumps(hashes), user_id))
conn.commit()
return True
return False
def disable(user_id: int) -> None:
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'"
" WHERE id=?", (user_id,))
conn.commit()
def remaining_backup_codes(user_id: int) -> int:
with get_conn() as conn:
row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
try:
return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0
except (TypeError, json.JSONDecodeError):
return 0
# ── pending 2FA challenge (signed, 5 min) ──────────────────────────────────
def mint_pending(user_id: int) -> str:
from itsdangerous import URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
return ser.dumps({"user_id": user_id})
def redeem_pending(token: str, max_age: int = 300) -> int | None:
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
try:
payload = ser.loads(token, max_age=max_age)
return int(payload.get("user_id", 0)) or None
except (BadSignature, SignatureExpired, ValueError):
return None
+415
View File
@@ -0,0 +1,415 @@
"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0).
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux
non-membres (404, comme une collection restricted).
- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut).
- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ;
``guest_shares`` (``/g/<token>``) ; ``page_views`` (compteurs journaliers).
"""
from __future__ import annotations
import datetime
import secrets
from app.db import get_conn
VERIFICATION_DAYS_DEFAULT = 90
# Roles, strongest first. Mirrors collection roles.
TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer")
_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))}
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _iso(dt: datetime.datetime) -> str:
return dt.replace(microsecond=0).isoformat()
# ── teamspaces ─────────────────────────────────────────────────────────────
def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None:
"""Explicit role, else workspace role, else ``None`` when unreachable."""
if not user_id:
return None
with get_conn() as conn:
ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?",
(teamspace_id,)).fetchone()
if not ts:
return None
row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, user_id)).fetchone()
if row:
return row["role"]
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if admin and admin["is_admin"]:
return "owner"
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
if owner and owner["owner_id"] == user_id:
return "owner"
if not ts["private"]:
# a public teamspace is still workspace-scoped: no workspace
# membership means no access (otherwise any logged-in account on
# the instance could read every public teamspace).
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ts["workspace_id"], user_id)).fetchone()
return member["role"] if member else None
return None
def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool:
return get_teamspace_role(user_id, teamspace_id) is not None
def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool:
role = get_teamspace_role(user_id, teamspace_id)
return role in ("owner", "editor")
def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]:
"""Teamspaces the user can see (private ones filtered out).
``workspace_id=None`` lists across every workspace (global sidebar).
"""
with get_conn() as conn:
if workspace_id is not None:
ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id)).fetchone()
if not ws_member:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(workspace_id,)).fetchone()
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if not owner or owner["owner_id"] != user_id:
if not (admin and admin["is_admin"]):
return []
rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name",
(workspace_id,)).fetchall()
else:
rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall()
out = []
for r in rows:
role = get_teamspace_role(user_id, r["id"])
if role is None:
continue
counts = conn.execute(
"""SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?)
+ (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""",
(r["id"], r["id"])).fetchone()
item = dict(r)
item["role"] = role
item["workspace_name"] = _workspace_name(conn, r["workspace_id"])
item["item_count"] = counts["n"]
out.append(item)
return out
def _workspace_name(conn, workspace_id: int) -> str:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
def teamspace_pages(teamspace_id: int) -> list[dict]:
"""Non-deleted pages belonging to a teamspace (title/URL order)."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, title, workspace_id, teamspace_id
FROM pages WHERE teamspace_id=? AND deleted_at IS NULL
ORDER BY title""", (teamspace_id,)).fetchall()]
def teamspace_collections(teamspace_id: int) -> list[dict]:
"""Databases belonging to a teamspace."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, name, icon FROM collections
WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()]
def create_teamspace(workspace_id: int, name: str, user_id: int,
description: str = "", private: bool = False) -> int:
with get_conn() as conn:
try:
cur = conn.execute(
"INSERT INTO teamspaces (workspace_id, name, description, private, created_by)"
" VALUES (?,?,?,?,?)",
(workspace_id, name.strip(), description[:400], 1 if private else 0, user_id))
except Exception as exc: # UNIQUE(workspace_id, name)
if "UNIQUE" in str(exc):
raise ValueError("A teamspace with this name already exists") from None
raise
conn.commit()
# the creator is owner
conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)"
" VALUES (?,?,'owner')", (cur.lastrowid, user_id))
conn.commit()
return cur.lastrowid
def teamspace_member_ids(teamspace_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM teamspace_members WHERE teamspace_id=?",
(teamspace_id,)).fetchall()]
# ── verified pages ─────────────────────────────────────────────────────────
def is_expired(row) -> bool:
if not row or not row["expires_at"]:
return False
try:
return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"])
except ValueError:
return False
def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT,
note: str = "") -> dict:
days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365))
expires = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_verifications (page_id, verified_by, note, expires_at)
VALUES (?,?,?,?)
ON CONFLICT(page_id) DO UPDATE SET
verified_by=excluded.verified_by, note=excluded.note,
verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""",
(page_id, user_id, note[:400], expires))
conn.commit()
return verification(page_id)
def verification(page_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"""SELECT v.*, u.full_name, u.login FROM page_verifications v
LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""",
(page_id,)).fetchone()
if not row:
return None
d = dict(row)
d["expired"] = is_expired(row)
d["active"] = not d["expired"]
return d
def unverify_page(page_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,))
conn.commit()
return bool(cur.rowcount)
def expiring_verifications(days: int = 7) -> list[dict]:
"""Verifications expiring within ``days`` (drives the owner notification).
``pages`` has no owner column in FlowDeck, so the reminder targets the user
who performed the verification.
"""
horizon = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
rows = conn.execute(
"""SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""",
(horizon,)).fetchall()
return [dict(r) for r in rows]
# ── follows ────────────────────────────────────────────────────────────────
def is_following(page_id: int, user_id: int) -> bool:
with get_conn() as conn:
return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone())
def toggle_follow(page_id: int, user_id: int) -> bool:
"""Returns the new state (True = now following)."""
with get_conn() as conn:
if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id))
conn.commit()
return False
conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
conn.commit()
return True
def ensure_follow(page_id: int, user_id: int, conn=None) -> bool:
"""Follow unless already following. Returns True when newly followed."""
if not user_id:
return False
def _run(c):
if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
return False
c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
return True
if conn is not None:
added = _run(conn)
conn.commit()
return added
with get_conn() as _c:
added = _run(_c)
_c.commit()
return added
def followers(page_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()]
# Rate-limit window for ``page.updated`` notifications: the block editor
# autosaves every ~1.5 s; without it followers would be spammed per keystroke.
_UPDATE_NOTIF_WINDOW_MIN = 10
def notify_followers_of_page_update(page_id: int, actor_id: int | None,
title: str = "") -> int:
"""Create a ``page.updated`` notification for every follower (except the
actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes.
Returns the number of notifications created."""
if not page_id:
return 0
from app.services.notifications import create_notification
with get_conn() as conn:
followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()
if not followed:
return 0
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
title = (title or (page["title"] if page else "") or "Untitled")
created = 0
for f in followed:
uid = f["user_id"]
if uid == actor_id:
continue
recent = conn.execute(
"""SELECT 1 FROM notifications
WHERE user_id=? AND resource_type='page' AND resource_id=?
AND ntype='page.updated'
AND created_at >= datetime('now', ?)""",
(uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone()
if recent:
continue
create_notification(
uid, actor_id, "page.updated",
title=f'"{title}" was updated',
message=f'Page "{title}" has been modified',
resource_type="page", resource_id=page_id,
url=f"/pages/{page_id}", conn=conn, commit=False,
)
created += 1
conn.commit()
return created
# ── comment reactions ──────────────────────────────────────────────────────
def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict:
"""Add or remove ``emoji``; returns the aggregated counts for the comment."""
emoji = (emoji or "").strip()[:16]
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone():
raise LookupError("Comment not found")
existing = conn.execute(
"SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?",
(comment_id, user_id, emoji)).fetchone()
if existing:
conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],))
conn.commit()
else:
conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)"
" VALUES (?,?,?)", (comment_id, user_id, emoji))
conn.commit()
return reactions(comment_id)
def reactions(comment_id: int) -> dict[str, dict]:
with get_conn() as conn:
rows = conn.execute(
"""SELECT emoji, COUNT(*) AS n,
GROUP_CONCAT(user_id) AS users
FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""",
(comment_id,)).fetchall()
return {r["emoji"]: {"count": r["n"],
"users": [int(u) for u in (r["users"] or "").split(",") if u]}
for r in rows}
# ── guest shares ───────────────────────────────────────────────────────────
def create_guest_share(page_id: int, email: str, role: str, created_by: int,
days: int | None = 30) -> dict:
if role not in ("viewer", "commenter"):
raise ValueError("role must be viewer or commenter")
token = f"g_{secrets.token_urlsafe(24)}"
expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at)
VALUES (?,?,?,?,?,?)""",
(page_id, email[:200], token, role, created_by, expires))
conn.commit()
return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?",
(cur.lastrowid,)).fetchone())
def resolve_guest_share(token: str) -> dict | None:
"""Active share for ``token``, or ``None`` (unknown / revoked / expired)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone()
if not row or row["revoked"]:
return None
if row["expires_at"]:
try:
if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]):
return None
except ValueError:
pass
return dict(row)
# ── page views ─────────────────────────────────────────────────────────────
def record_view(page_id: int, day: str | None = None) -> int:
day = day or _utcnow().strftime("%Y-%m-%d")
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_views (page_id, day, views) VALUES (?,?,1)
ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""",
(page_id, day))
conn.commit()
row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?",
(page_id, day)).fetchone()
return row["views"]
def view_stats(page_id: int, days: int = 30) -> dict:
days = max(1, min(int(days or 30), 365))
since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d")
with get_conn() as conn:
rows = conn.execute(
"SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day",
(page_id, since)).fetchall()
series = {r["day"]: r["views"] for r in rows}
# fill the gap so charts have no holes
out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1)
for _ in range(days):
key = cursor.strftime("%Y-%m-%d")
out.append({"day": key, "views": series.get(key, 0)})
cursor += datetime.timedelta(days=1)
return {"page_id": page_id, "days": days, "total": sum(series.values()),
"series": out}
+196
View File
@@ -0,0 +1,196 @@
"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress.
Server-side rendering so HTML/PDF export embeds real content (the editor
already has Prism + KaTeX client-side). Design §2 of
``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``mermaid`` : SVG via ``mmdc`` when installed, else a ``<pre>`` fallback
that mermaid.js can still render in the browser.
- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
falls back to readable plaintext.
- ``progress`` : ``{"rollup_ref": {collection_id, property_id}}`` → percent
bar computed with :class:`RollupEngine`.
"""
from __future__ import annotations
import html
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
# ── mermaid ────────────────────────────────────────────────────────────────
def mmdc_available() -> bool:
return shutil.which("mmdc") is not None
def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
"""Render mermaid source to an inline SVG, or ``None`` if unavailable.
Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
degrade to the code fallback.
"""
source = (source or "").strip()
if not source or not mmdc_available():
return None
with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
src = Path(tmp) / "diagram.mmd"
out = Path(tmp) / "diagram.svg"
src.write_text(source, encoding="utf-8")
try:
subprocess.run(
["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
capture_output=True, timeout=timeout, check=True)
except (subprocess.SubprocessError, OSError):
return None
if not out.exists():
return None
svg = out.read_text(encoding="utf-8", errors="replace")
# strip the XML prolog / doctype so the SVG can be inlined
svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
return svg or None
def render_mermaid(source: str) -> str:
"""Inline SVG when possible, else a mermaid-renderable code block."""
svg = mermaid_to_svg(source)
if svg:
return f'<figure class="mermaid-figure">{svg}</figure>'
return (f'<pre class="mermaid"><code class="language-mermaid">'
f'{html.escape(source or "")}</code></pre>')
# ── equations ──────────────────────────────────────────────────────────────
# Only characters that are meaningful inside a math expression are kept. Note
# that `<`, `>` and `\` are NOT allowed: they would let the source close the
# KaTeX delimiter early or inject markup into the exported HTML.
_EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]")
def sanitize_equation(source: str) -> str:
return _EQUATION_ALLOWED.sub("", source or "").strip()
def render_equation(source: str) -> str:
expr = sanitize_equation(source)
if not expr:
return ""
return (f'<span class="fd-equation" data-equation="{html.escape(expr)}">'
f'\\({html.escape(expr)}\\)</span>')
# ── progress ───────────────────────────────────────────────────────────────
def _rows_values(collection_id: int) -> list[dict]:
import json as _json
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
out = []
for r in rows:
try:
out.append(_json.loads(r["property_values_json"] or "{}"))
except ValueError:
out.append({})
return out
_DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed")
def _is_done(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in _DONE_TRUE
return False
def progress_value(rollup_ref: dict | None) -> dict:
"""Resolve ``{collection_id, done_property_id | property_id, func}`` to a
0..100 percent.
Aggregates directly over ``collection_pages.property_values_json`` (the
relation-based :class:`RollupEngine` needs a relation chain + a source
page, which a page-embedded progress bar does not have).
"""
from app.db import get_conn
if not rollup_ref or not rollup_ref.get("collection_id"):
return {"percent": None, "done": None, "total": None}
cid = rollup_ref["collection_id"]
values = _rows_values(cid)
total = len(values)
done_pid = rollup_ref.get("done_property_id")
if done_pid:
key = str(done_pid)
done = sum(1 for vals in values if _is_done(vals.get(key)))
if total:
return {"percent": round(done * 100 / total, 1), "done": done, "total": total}
return {"percent": 0.0, "done": 0, "total": 0}
pid = rollup_ref.get("property_id")
if not pid or not total:
return {"percent": None, "done": None, "total": total}
key = str(pid)
with get_conn() as conn:
prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND"
" collection_id=?", (pid, cid)).fetchone()
if not prop:
return {"percent": None, "done": None, "total": total}
func = (rollup_ref.get("func") or "count").lower()
if prop["prop_type"] in ("checkbox", "status", "select"):
done = sum(1 for vals in values if _is_done(vals.get(key)))
elif prop["prop_type"] in ("number", "formula", "rollup"):
nums = []
for vals in values:
v = vals.get(key)
try:
nums.append(float(v))
except (TypeError, ValueError):
continue
if not nums:
return {"percent": None, "done": None, "total": total}
done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs),
"max": max, "min": min}.get(func, len)(nums)
else:
done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {}))
if not total:
return {"percent": 0.0, "done": done, "total": 0}
return {"percent": round(min(float(done) * 100 / total, 100), 1),
"done": done, "total": total}
def render_progress(block: dict) -> str:
stats = progress_value(block.get("rollup_ref") or block)
pct = stats["percent"]
label = block.get("label") or "Progress"
if pct is None:
return (f'<div class="fd-progress" data-percent=""><div class="fd-progress-label">'
f'{html.escape(label)}: —</div></div>')
detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else ""
return (f'<div class="fd-progress" data-percent="{pct}">'
f'<div class="fd-progress-label">{html.escape(label)}: {pct}%'
f'{(" (" + html.escape(detail) + ")") if detail else ""}</div>'
f'<div class="fd-progress-bar"><div class="fd-progress-fill"'
f' style="width:{min(pct, 100)}%"></div></div></div>')
def render_block(block: dict) -> str:
"""Dispatch for the three v7.3 block types (used by export + preview API)."""
t = block.get("type")
if t == "mermaid":
return render_mermaid(block.get("content") or block.get("source") or "")
if t == "equation_inline":
return render_equation(block.get("content") or "")
if t == "progress":
return render_progress(block)
return ""
+234
View File
@@ -0,0 +1,234 @@
"""FlowDeck — Workers lite (v7.0.0).
Custom Python snippets run on FlowDeck infrastructure: manual, on a cron
schedule, or shared across the team (fork). Parité Notion Workers (07/2026),
sans facturation : un budget journalier secondes/workspace fait office de
« credits dashboard ».
Sandbox (documenté, best-effort single-process) :
- AST blacklist : ``import os/sys/subprocess/socket``, ``open()``,
``exec/eval/compile``, attributs dunder.
- Pas de réseau, pas de FS ; builtins restreints (pas de ``__import__``).
- Timeout 30 s (thread + join), budget journalier ``daily_budget_s``.
- Seules API exposées : ``log()``, ``ctx`` (dict), ``result`` (dict out).
Voir ``docs/V70_Automations_Workers.md``.
"""
from __future__ import annotations
import ast
import asyncio
import io
import logging
import re
import time
from contextlib import redirect_stdout
from datetime import UTC, datetime
from app.db import get_conn
logger = logging.getLogger(__name__)
RUN_TIMEOUT_S = 30
MAX_CODE_CHARS = 20_000
MAX_LOG_CHARS = 10_000
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,60}$")
_FORBIDDEN_IMPORTS = {"os", "sys", "subprocess", "socket", "shutil",
"pathlib", "io", "asyncio", "threading", "multiprocessing"}
_FORBIDDEN_CALLS = {"open", "exec", "eval", "compile", "__import__"}
class WorkerRejected(ValueError):
"""Raised when worker code violates the sandbox policy."""
def validate_code(code: str) -> None:
"""AST lint of worker code. Raises WorkerRejected on violation."""
code = code or ""
if len(code) > MAX_CODE_CHARS:
raise WorkerRejected(f"code too long ({len(code)} > {MAX_CODE_CHARS})")
try:
tree = ast.parse(code)
except SyntaxError as exc:
raise WorkerRejected(f"syntax error: {exc}") from None
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
names = [a.name.split(".")[0] for a in node.names]
if getattr(node, "module", None):
names.append(str(node.module).split(".")[0])
for name in names:
if name in _FORBIDDEN_IMPORTS:
raise WorkerRejected(f"import forbidden: {name}")
elif isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in _FORBIDDEN_CALLS:
raise WorkerRejected(f"call forbidden: {func.id}()")
elif isinstance(node, ast.Attribute):
if isinstance(node.attr, str) and node.attr.startswith("__"):
raise WorkerRejected(f"dunder access forbidden: {node.attr}")
def _slugify(name: str) -> str:
import unicodedata
slug = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
return re.sub(r"[-\s]+", "-", slug).strip("-") or "worker"
def unique_slug(base: str, ignore_id: int | None = None) -> str:
slug, i = _slugify(base)[:60] or "worker", 1
with get_conn() as conn:
while conn.execute(
"SELECT id FROM workers WHERE slug=? AND id != COALESCE(?, -1)",
(slug, ignore_id)).fetchone():
i += 1
slug = f"{_slugify(base)[:55]}-{i}"
return slug
_SAFE_BUILTINS = {
"abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
"frozenset": frozenset, "int": int, "len": len, "list": list, "map": map,
"max": max, "min": min, "range": range, "reversed": reversed, "round": round,
"set": set, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"zip": zip, "print": print, "isinstance": isinstance, "type": type,
}
def _exec_code(code: str, ctx: dict) -> tuple[dict, str]:
"""Run validated code in a thread. Returns (result_dict, logs)."""
logs: list[str] = []
def _log(*args) -> None:
logs.append(" ".join(str(a) for a in args))
namespace = {"__builtins__": dict(_SAFE_BUILTINS),
"log": _log, "ctx": dict(ctx or {}), "result": {}}
buf = io.StringIO()
with redirect_stdout(buf):
exec(compile(code, "<worker>", "exec"), namespace) # noqa: S102 — sandboxed
printed = buf.getvalue()
if printed:
logs.append(printed)
result = namespace.get("result")
return result if isinstance(result, dict) else {}, "\n".join(logs)[:MAX_LOG_CHARS]
def daily_usage_s(workspace_id: int | None) -> float:
"""CPU seconds consumed today (UTC) by a workspace's workers."""
day = datetime.now(UTC).strftime("%Y-%m-%d")
with get_conn() as conn:
row = conn.execute(
"""SELECT COALESCE(SUM(wr.duration_ms), 0) FROM worker_runs wr
JOIN workers w ON w.id = wr.worker_id
WHERE date(wr.created_at) = date(?)
AND COALESCE(w.workspace_id, -1) = COALESCE(?, -1)""",
(day, workspace_id)).fetchone()
return (row[0] or 0) / 1000.0
def _save_run(worker_id: int, status: str, logs: str, duration_ms: int) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO worker_runs (worker_id, status, logs, duration_ms)"
" VALUES (?,?,?,?)", (worker_id, status, logs[:MAX_LOG_CHARS], duration_ms))
conn.commit()
return cur.lastrowid
def run_worker(worker_id: int, ctx: dict | None = None) -> dict:
"""Execute a worker synchronously (used by the router + cron loop).
Returns {status, run_id, duration_ms}. Never raises for user-code errors
(they become ``error`` runs); raises only when the worker is missing or
over budget (caller maps to 404/429).
"""
from fastapi import HTTPException
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
worker = dict(row)
validate_code(worker.get("code_py") or "")
used = daily_usage_s(worker.get("workspace_id"))
if used >= (worker.get("daily_budget_s") or 60):
_save_run(worker_id, "over_budget", f"daily budget exceeded ({used:.1f}s used)", 0)
raise HTTPException(429, "Worker daily budget exceeded")
outcome: dict = {}
def _target() -> None:
try:
result, logs = _exec_code(worker.get("code_py") or "", ctx or {})
outcome["result"] = result
outcome["logs"] = logs
except Exception as exc: # noqa: BLE001 — user code, recorded
outcome["error"] = f"{type(exc).__name__}: {exc}"
import threading
started = time.time()
thread = threading.Thread(target=_target, daemon=True)
thread.start()
thread.join(timeout=RUN_TIMEOUT_S)
duration_ms = int((time.time() - started) * 1000)
if thread.is_alive():
run_id = _save_run(worker_id, "timeout",
f"exceeded {RUN_TIMEOUT_S}s timeout", duration_ms)
return {"status": "timeout", "run_id": run_id, "duration_ms": duration_ms}
if "error" in outcome:
run_id = _save_run(worker_id, "error", outcome["error"], duration_ms)
return {"status": "error", "run_id": run_id,
"duration_ms": duration_ms, "error": outcome["error"]}
run_id = _save_run(worker_id, "ok", outcome.get("logs", ""), duration_ms)
return {"status": "ok", "run_id": run_id, "duration_ms": duration_ms,
"result": outcome.get("result", {})}
async def run_due_workers() -> int:
"""Fire workers whose ``schedule_cron`` is due (called from the 60s loop)."""
from app.services.automations import cron_due
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM workers WHERE schedule_cron IS NOT NULL AND schedule_cron != ''"
).fetchall()
fired = 0
for row in rows:
worker = dict(row)
with get_conn() as conn:
last = conn.execute(
"SELECT MAX(created_at) FROM worker_runs WHERE worker_id=?",
(worker["id"],)).fetchone()[0]
try:
if cron_due(worker["schedule_cron"] or "", last):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, run_worker, worker["id"], {})
fired += 1
except Exception as exc: # noqa: BLE001 — one worker must not kill the loop
logger.debug("worker %s cron failed: %s", worker["id"], exc)
return fired
def fork_worker(worker_id: int, user_id: int) -> dict:
"""Duplicate a shared worker for another user (Notion-style sharing)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
from fastapi import HTTPException
raise HTTPException(404, "Worker not found")
src = dict(row)
if not src.get("shared") and src.get("created_by") != user_id:
from fastapi import HTTPException
raise HTTPException(403, "Worker is not shared")
slug = unique_slug(f"{src['slug']}-fork")
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
shared, daily_budget_s, created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(slug, src["workspace_id"], f"{src['name']} (fork)", src["code_py"],
"", 0, src["daily_budget_s"], user_id))
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "slug": slug, "status": "forked", "from": worker_id}
+1 -1
View File
@@ -2393,7 +2393,7 @@ applyAIBlocks(text){
if(this.saving){ if(cb) cb(); return; }
this.sync();
this.saving=true;
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name','url','title','description','image','site_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
const queueOffline=()=>{
if(!window.FlowOffline){this.saving=false;return;}
+8 -8
View File
@@ -555,7 +555,7 @@
var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'},
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
return resp.json();
@@ -701,7 +701,7 @@
if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){
@@ -824,7 +824,7 @@
installGallerySkill(slug, icon, name){
var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
@@ -988,7 +988,7 @@
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1005,7 +1005,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1027,7 +1027,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
@@ -1832,7 +1832,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1930,7 +1930,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+49 -9
View File
@@ -404,6 +404,33 @@
</div>
</div>
<!-- Teamspaces -->
<div class="sidebar-section" x-show="isSectionVisible('teamspaces')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('teamspaces')" @contextmenu.prevent="openSectionMenu($event, 'teamspaces')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.teamspaces }">▶</span>
<span class="section-icon">{{ fd_icon("home",14) }}</span>
<span class="section-label">Teamspaces</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="loadTeamspaces()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'teamspaces')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.teamspaces" x-transition>
<ul class="sidebar-items" data-section="teamspaces">
<template x-for="t in teamspaces" :key="t.id">
<li class="sidebar-item" @click="openTeamspace(t.id)">
<span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span>
<span class="page-name" x-text="t.name"></span>
<span class="page-name text-dim" style="font-size:11px;" x-text="t.item_count + (t.private ? ' · 🔒' : '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!teamspaces.length"><span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span><span class="page-name text-dim">No teamspaces yet</span></li>
</ul>
</div>
</div>
<!-- Shared -->
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
@@ -503,9 +530,10 @@
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
recents:{visible:true,order:2},favorites:{visible:true,order:3},
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
meetings:{visible:true,order:2},recents:{visible:true,order:3},
favorites:{visible:true,order:4},agents:{visible:true,order:5},
shared:{visible:true,order:6},published:{visible:true,order:7}
})" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
@@ -1098,6 +1126,7 @@
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
this.loadTeamspaces();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1157,6 +1186,17 @@
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
},
teamspaces: [],
loadTeamspaces() {
var self = this;
fetch('/api/v2/wiki/teamspaces').then(function(r){ return r.json(); }).then(function(d){
self.teamspaces = d.teamspaces || [];
}).catch(function(){});
},
openTeamspace(id) {
var target = '/wiki/teamspaces/' + id;
if (window.fdNavigate) window.fdNavigate(target); else window.location = target;
},
sidebarCollapsed: false,
sidebarPeek: false,
mobileSidebarOpen: false,
@@ -1310,7 +1350,7 @@
// ── Sections collapsible ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
@@ -1330,7 +1370,7 @@
loadSidebarConfig() {
var self = this;
try {
var raw = {{ sidebar_config|default('{}')|safe }};
var raw = {{ sidebar_config|default({})|tojson }};
if (raw && raw.config) {
self.sidebarConfig = raw.config;
} else if (typeof raw === 'object') {
@@ -1361,7 +1401,7 @@
getSectionOrder(key) {
var cfg = this.sidebarConfig[key];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
if (cfg && typeof cfg.order === 'number') return cfg.order;
return sections.indexOf(key);
},
@@ -1380,7 +1420,7 @@
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
}) :
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
var idx = sections.indexOf(key);
if (idx < 0) return;
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
@@ -1422,7 +1462,7 @@
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
agents: '🤖', teamspaces: '🏛️', shared: '👥', published: '🌐', gitea: '🔗',
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
help: '❓', trash: '🗑️'
};
@@ -1432,7 +1472,7 @@
var labels = {
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
shared: 'Shared', published: 'Published', private: 'Private',
teamspaces: 'Teamspaces', shared: 'Shared', published: 'Published', private: 'Private',
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
help: 'Help', trash: 'Trash'
};
+2 -2
View File
@@ -45,7 +45,7 @@
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
contenteditable="true"
@blur="updateField('body', $event.target.innerHTML)">
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
</div>
<!-- Checklists -->
@@ -82,7 +82,7 @@
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
</div>
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
{{ comment.body|safe }}
{{ comment.body }}
</div>
</div>
</div>
+4 -4
View File
@@ -135,7 +135,7 @@ document.addEventListener('alpine:init', () => {
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
@@ -150,7 +150,7 @@ document.addEventListener('alpine:init', () => {
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
@@ -387,7 +387,7 @@ document.addEventListener('alpine:init', () => {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
});
if (r.ok) {
+2 -2
View File
@@ -1198,7 +1198,7 @@ function libraryPage() {
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
@@ -1218,7 +1218,7 @@ function libraryPage() {
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
+17 -16
View File
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
@@ -1124,7 +1124,8 @@ window._wsInitData = (function() {
try {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
@@ -1206,7 +1207,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
@@ -1343,7 +1344,7 @@ window._wsInitData = (function() {
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
@@ -1523,7 +1524,7 @@ window._wsInitData = (function() {
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
@@ -1578,7 +1579,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
@@ -1589,7 +1590,7 @@ window._wsInitData = (function() {
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
@@ -1617,7 +1618,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
@@ -1642,7 +1643,7 @@ window._wsInitData = (function() {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
.then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); });
}
@@ -1940,7 +1941,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
@@ -2024,7 +2025,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
@@ -2037,7 +2038,7 @@ window._wsInitData = (function() {
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
@@ -2230,7 +2231,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
@@ -2326,7 +2327,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
@@ -2345,7 +2346,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
});
if (r.ok) {
@@ -2491,7 +2492,7 @@ window._wsInitData = (function() {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch(e) {}
+337 -30
View File
@@ -153,6 +153,7 @@
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div>
</template>
</div>
@@ -809,35 +810,57 @@
<!-- Admin: Audit Log -->
<div x-show="activeSection==='admin-audit'">
<h2>Audit Log</h2>
<p class="section-desc">Login history and security events.</p>
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
<span style="flex:1;"></span>
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
</div>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Source</th>
<th>Actor</th>
<th>Action</th>
<th>Resource</th>
<th>IP</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
<template x-for="e in auditEntries" :key="e.id">
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
<td>
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
</td>
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
</tr>
</template>
<tr x-show="auditEntries.length === 0">
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
<tr x-show="!auditLogs.length && !auditLoading">
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
</tr>
</tbody>
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
</style>
</div>
<!-- Admin: Backups -->
@@ -870,6 +893,141 @@
</div>
</div>
<!-- Admin: SSO / Enterprise (v6.7.0) -->
<div x-show="activeSection==='admin-sso'">
<h2>SSO / Enterprise</h2>
<p class="section-desc">Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.</p>
<!-- Status -->
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
<div class="llm-summary-icon">🔐</div>
<div class="llm-summary-body">
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
</div>
<div class="llm-summary-side">
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
</div>
</div>
<!-- Provider -->
<div class="setting-group">
<h3>Fournisseur</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
<option value="saml">SAML 2.0</option>
<option value="oidc">OpenID Connect</option>
</select>
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
</div>
</div>
<!-- SAML -->
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
<h3>Configuration SAML</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
</label>
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<span>SP metadata à donner à l'IdP :</span>
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
</div>
</div>
<!-- OIDC -->
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
<h3>Configuration OpenID Connect</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
<div class="pw-wrapper" style="max-width:560px;">
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
style="padding-right:36px;">
</div>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
</div>
<!-- Provisioning -->
<div class="setting-group">
<h3>Provisioning &amp; accès</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
</label>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
</label>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
<option value="">— Aucun —</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</div>
<!-- Group mapping -->
<div class="setting-group">
<h3>Groupes IdP → rôles workspace</h3>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
<tbody>
<template x-for="(g, i) in ssoGroups" :key="i">
<tr>
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
<td>
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</td>
<td>
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
<option value="">Espace par défaut</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</td>
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
</tr>
</template>
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
</tbody>
</table>
</div>
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
</div>
<!-- Actions -->
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
</div>
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
</div>
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
@@ -1067,6 +1225,7 @@ function settingsInit() {
adminUsers: [],
adminStats: {},
auditEntries: [],
auditLogs: [],
showCreateUser: false,
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
@@ -1115,6 +1274,17 @@ function settingsInit() {
backupMsg: '',
backupOk: false,
// v6.7.0 SSO / Enterprise (admin)
ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
ssoAttrJson: '{}',
ssoGroups: [],
ssoWorkspaces: [],
ssoSource: 'db',
ssoProvisioned: 0,
ssoSaving: false,
ssoMsg: '',
ssoOk: false,
async init() {
await this.loadTags();
await this.loadGiteaStatus();
@@ -1167,7 +1337,7 @@ function settingsInit() {
var n = this.newTagName.trim();
if (!n) return;
var r = await fetch('/api/settings/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: n, color: this.newTagColor})
});
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
@@ -1175,7 +1345,7 @@ function settingsInit() {
async updateTagColor(id, color) {
await fetch('/api/settings/tags/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
await this.loadTags();
@@ -1183,7 +1353,7 @@ function settingsInit() {
async deleteTag(id) {
if (!confirm('Delete this tag?')) return;
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await this.loadTags();
},
@@ -1205,7 +1375,7 @@ function settingsInit() {
this.renamingTag = null; return;
}
await fetch('/api/settings/tags/' + tag.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
body: JSON.stringify({name: newName})
});
this.renamingTag = null;
@@ -1247,11 +1417,41 @@ function settingsInit() {
},
async loadAdminAudit() {
await this.loadAuditLogs();
},
// ── Unified Audit API (v7.3.0) ──
auditSource: 'all',
auditActor: '',
auditAction: '',
auditOffset: 0,
auditPageSize: 50,
auditLoading: false,
auditHasMore: false,
async loadAuditLogs(reset) {
if (reset !== false) { this.auditOffset = 0; }
this.auditLoading = true;
try {
var r = await this.adminFetch('/api/admin/audit?limit=200');
var d = await r.json();
this.auditEntries = d.entries || [];
} catch(e) { this.auditEntries = []; }
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
const r = await fetch('/api/v2/audit/logs?' + params.toString());
if (!r.ok) { throw new Error('HTTP ' + r.status); }
const d = await r.json();
const logs = d.logs || [];
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
this.auditHasMore = logs.length >= this.auditPageSize;
} catch(e) {
if (reset !== false) this.auditLogs = [];
this.auditHasMore = false;
}
this.auditLoading = false;
},
auditCsvUrl() {
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
return '/api/v2/audit/logs?' + params.toString();
},
async adminCreateUser() {
@@ -1448,7 +1648,7 @@ function settingsInit() {
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
@@ -1477,7 +1677,7 @@ function settingsInit() {
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1513,7 +1713,7 @@ function settingsInit() {
if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1539,7 +1739,7 @@ function settingsInit() {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
@@ -1699,7 +1899,7 @@ function settingsInit() {
if (!file) return;
var form = new FormData();
form.append('file', file);
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
if (r.ok) {
var d = await r.json();
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
@@ -1710,7 +1910,7 @@ function settingsInit() {
async selectAvatarColor(color) {
this.avatarColor = color;
var r = await fetch('/api/settings/avatar-color', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
if (r.ok) { this.avatarUrl = ''; }
@@ -1855,7 +2055,7 @@ function settingsInit() {
// ── v5.2.0 API tokens ──
async loadApiTokens() {
try {
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
var d = await r.json();
this.apiTokens = d.tokens || [];
} catch(e) { this.apiTokens = []; }
@@ -1865,7 +2065,7 @@ function settingsInit() {
if (!name) return;
try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d;
@@ -1935,6 +2135,113 @@ function settingsInit() {
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
finally { this.clipTesting = false; }
},
// ── v6.7.0 SSO / Enterprise (admin) ──
ssoStatusTitle() {
if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
},
ssoStatusDesc() {
var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
},
ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
async copySsoMetadata() {
try {
await navigator.clipboard.writeText(this.ssoMetadataUrl());
if (typeof toast === 'function') toast('SP metadata URL copiée');
} catch(e) {}
},
async loadSsoConfig() {
this.ssoMsg = '';
try {
var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
if (r.ok) {
var d = await r.json();
this.ssoSource = d.source || 'db';
this.ssoProvisioned = d.provisioned_users || 0;
if (d.configured === false) {
this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
} else {
this.ssoCfg = {
id: d.id || null,
provider_type: d.provider_type || 'saml',
name: d.name || '',
entity_id: d.entity_id || '',
sso_url: d.sso_url || '',
slo_url: d.slo_url || '',
x509_certificate: d.x509_certificate || '',
issuer_url: d.issuer_url || '',
client_id: d.client_id || '',
client_secret: '',
client_secret_set: !!d.client_secret_set,
scope: d.scope || 'openid profile email',
attribute_mapping: d.attribute_mapping || {},
auto_provision: !!d.auto_provision,
sso_only: !!d.sso_only,
sign_requests: !!d.sign_requests,
default_workspace_id: d.default_workspace_id || ''
};
this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
}
}
} catch(e) {}
try {
var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
if (w.ok) {
var wd = await w.json();
this.ssoWorkspaces = wd.workspaces || [];
this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
}
} catch(e) {}
},
async saveSsoConfig() {
this.ssoMsg = '';
var amap = {};
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
}
this.ssoSaving = true;
var payload = Object.assign({}, this.ssoCfg, {
attribute_mapping: amap,
groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
sso_only: this.ssoCfg.sso_only ? 1 : 0,
sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
});
delete payload.id;
try {
var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
var d = null; try { d = await r.json(); } catch(e) {}
if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
finally { this.ssoSaving = false; }
},
async disableSso() {
if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
this.ssoMsg = '';
try {
var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
},
async syncSsoGroups() {
this.ssoMsg = '';
try {
var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
var d = null; try { d = await r.json(); } catch(e) {}
if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
},
addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
// ── v5.2.0 Backups (admin) ──
async loadBackups() {
try {
+2 -2
View File
@@ -155,7 +155,7 @@ function onboarding() {
async createWorkspace() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({name:this.wsName.trim()})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.workspaceId = d.id;
@@ -172,7 +172,7 @@ function onboarding() {
async createProject() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.toast('Projet créé 🎉');
+1 -1
View File
@@ -174,7 +174,7 @@ function workspacePage() {
if (!this.newProjectName.trim()) return;
const r = await fetch('/api/workspace/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify({name: this.newProjectName.trim()})
});
if (r.ok) {
+4 -4
View File
@@ -214,7 +214,7 @@ function workspacesPage() {
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
window.location = '/local-workspace';
},
@@ -222,7 +222,7 @@ function workspacesPage() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -240,7 +240,7 @@ function workspacesPage() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
@@ -251,7 +251,7 @@ function workspacesPage() {
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await this.load();
},
+14
View File
@@ -0,0 +1,14 @@
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
les `|safe` du codebase étaient des no-op.
"""
from __future__ import annotations
from jinja2 import Environment, FileSystemLoader, select_autoescape
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
)
+39 -3
View File
@@ -7,6 +7,10 @@
> marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique
> partagée `app/services/skill_gallery.py`, OpenAPI régénéré (**427 chemins**), 15 tests dédiés
> (`tests/test_v66_agent_api.py`). Voir §2.4.
> **v6.7.0 (2026-09-24)** — **SSO / SAML + OIDC entreprise** : parcours navigateur
> `/auth/saml/*` + `/auth/oidc/*` (hors scope `/api/v2`), API admin de configuration
> `/api/v2/sso/*` (session + admin + CSRF), logique partagée `app/services/sso_provisioning.py`,
> OpenAPI régénéré, 38 tests dédiés (`tests/test_v67_sso.py`). Voir §2.5.
> Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception.
> **Dernière mise à jour** : 2026-09-24
> **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation.
@@ -259,9 +263,41 @@ Mêmes endpoints (session cookie) côté interne : `/api/agent/skills/gallery`,
(catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible).
4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …).
---
### 2.5 SSO / Enterprise auth (`app/routers/sso.py`, v6.7.0)
## 3. Conventions cibles pour l'API v2
> **Authentification fédérée** : un IdP d'entreprise (SAML 2.0 ou OpenID Connect + PKCE)
> ouvre une session FlowDeck ; les comptes sont créés au premier login et les groupes de
> l'IdP deviennent des rôles workspace. Design : `docs/V6_SSO_SAML_Enterprise_Auth.md`.
**Parcours navigateur (session cookie, CSRF exclu — POST IdP cross-site)**
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/auth/saml/login` | Redirection SP-initiée vers l'IdP (`RelayState` = next sûr) |
| POST | `/auth/saml/callback` | Assertion Consumer Service — signature/aud/dest/InResponseTo validés |
| GET | `/auth/saml/metadata` | Métadonnées SP XML (EntityID, ACS, SLO, certificat) |
| GET | `/auth/saml/logout` | SLO — relaye `SAMLRequest` à l'IdP puis détruit la session (POST accepté pour l'IdP) |
| GET | `/auth/oidc/login` | Redirection authorize (`state` + `code_verifier` en DB) |
| GET | `/auth/oidc/callback` | Code → token → userinfo ; ID token vérifié (JWKS, aud/iss/nonce/exp) |
| GET/POST | `/auth/oidc/logout` | Déconnexion OIDC (relaye à l'IdP si `end_session_endpoint`) |
**API admin (session + rôle admin + header `X-CSRF-Token`)**
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/sso/providers` | **Public** (page de login) : `{providers[{type,name,icon,login_url}], sso_only, base_url}` |
| GET | `/api/v2/sso/config` | Config publique (`{configured, source: db\|env, client_secret_set, provisioned_users}` — **jamais** le secret) |
| POST/PUT | `/api/v2/sso/config` | Crée/met à jour (`{configured:false}` = actif) ; champ secret vide = conserver |
| DELETE | `/api/v2/sso/config` | Désactive le SSO (les comptes SSO existants restent) |
| GET | `/api/v2/sso/workspaces` | Épingles pour le mapping (workspaces + `provisioned_users`) |
| POST | `/api/v2/sso/sync` | Re-synchronise les groupes de tous les utilisateurs SSO |
| GET | `/api/v2/sso/history` | Journal d'audit des tentatives (`limit` ≤ 200) |
Règles : secrets chiffrés Fernet (`sso_config`), anti-replay `sso_requests` (TTL 15 min),
historique `sso_login_history` (succès/échecs), mode SSO only (login local refusé sauf admins),
bootstrap possible par variables `SSO_*` du `.env` (la config admin prime).
---
### 3.1 Auth & tokens
@@ -736,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` généré (402 chemins).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
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`.
+121
View File
@@ -0,0 +1,121 @@
# V6.8.0 — Sites & Forms publics
> **Statut** : ✅ Livré en v6.8.0 (2026-09-28) — `app/routers/sites.py`, migration 24, 20 tests verts · **Roadmap** : `ROADMAP.md § v6.8.0`
> **Référence Notion** : Sites (multi-pages, domaine custom, SEO, analytics, password/expiry) + Forms (soumission anonyme → DB, embed, notifs).
> **Existant réutilisé** : page publique `/p/<slug>` (`dashboard.py`), `_render_blocks_public`, `export.py` (récursif + images), `validate_property_rule` (`property_types.py`), `notifications` + `mailer.py`, vue Form (`_database_table_scripts.html`).
---
## 1. Vision
Passer de « une page publiée isolée » à « publier un mini-site » et « collecter des réponses » :
`page privée → site (arbre + thème + gating) → URL publique /s/<slug>` et
`collection → form public /f/<token> → ligne collection_pages`.
Hors scope : builder drag&drop marketing, paiement, commentaires publics.
## 2. Sites multi-pages
### 2.1 Schéma (migration 24)
```sql
CREATE TABLE sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, -- /s/<slug>
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'dark', -- light|dark
custom_domain TEXT UNIQUE, -- Host header match
password_hash TEXT, -- NULL = public
expires_at TIMESTAMP, -- NULL = jamais
noindex BOOLEAN NOT NULL DEFAULT 0,
analytics_id TEXT DEFAULT '', -- Plausible/GA, pas d'IP brute
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE site_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE(site_id, page_id)
);
CREATE TABLE site_views (
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
day TEXT NOT NULL, -- YYYY-MM-DD UTC
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, day)
);
```
Règles : arbre construit depuis `site_pages` ordonné ; page hors site → 404 ; page supprimée → exclue + état « Deleted » ; `resolve_content_json()` (synced) appliqué comme `/p/<slug>`.
### 2.2 Gating & SEO
- Mot de passe : bcrypt, cookie signé `site_auth_<id>` 24h, `GET/POST /s/<slug>/auth`.
- Expiry : `expires_at < now` → 410 « Site expiré ».
- SEO : `<title>`, meta description (1er paragraphe), OG/Twitter (cover+icône), `sitemap.xml`, `robots.txt` (`noindex` → `noindex,nofollow`).
- Stats : `+1/jour/site` sur chaque vue (upsert), `GET /api/v2/sites/{id}/stats?days=30`.
### 2.3 Routes
```
GET /s/<slug> → home (root_page + nav)
GET /s/<slug>/<page-slug> → page du site (nav active)
GET /s/<slug>/sitemap.xml
GET|POST /s/<slug>/auth → gate password
GET|POST|PATCH|DELETE /api/v2/sites
GET|POST|DELETE /api/v2/sites/{id}/pages
GET /api/v2/sites/{id}/stats
```
Domaine custom : si `Host == custom_domain` → monte le site sans `/s/<slug>`.
## 3. Forms publics
### 3.1 Config (colonne `collections.form_config_json`)
```json
{
"enabled": true, "public_token": "f_abc123",
"title": "Contact", "success_message": "Merci !",
"fields": ["Name", "Email", "Message"],
"required": ["Name", "Email"],
"notify_user_ids": [1, 2]
}
```
Table `form_responses (id, collection_id, row_id, ip_hash, created_at)` — `ip_hash` = sha256(IP+jour), jamais d'IP brute.
### 3.2 Soumission anonyme
```
GET /f/<token> → formulaire thèmable (no-auth, `?embed=1` sans chrome)
POST /f/<token> → valide via validate_property_rule → 400 + messages
rate-limit 20/h/IP → 429, honeypot __hp + Turnstile optionnel
→ collection_pages + form_responses + notif in-app/email
```
Trigger automation `form.submitted` (v7.0.0 s'y branchera).
Embed : `<iframe src="https://host/f/<token>?embed=1" width="100%" height="600">`.
## 4. UI
- Éditeur « … » → `Share → Publish → Site` : créer site, choisir pages, thème, password/expiry/noindex, copier URL + snippet.
- Collection → `Views → Form → Share form` : toggle public, champs affichés, message succès, liste réponses (compteur + lien lignes filtrées `form:true`).
- Settings → Sites (liste, stats sparkline, domaine custom, revoke).
## 5. Sécurité / perfs
- Password bcrypt cost 12, cookie `itsdangerous` signé, CSRF exempt uniquement `POST /f/<token>` (rate-limit + honeypot compensent).
- `custom_domain` validé (hostname, pas d'IP privée) ; `slug` `^[a-z0-9-]{3,50}$`.
- Cache rendu public 60s (`Cache-Control: public, max-age=60`), stats en upsert (pas de ligne/vue).
## 6. Tests (`tests/test_v68_sites_forms.py`, ~25)
CRUD site, nav ordonnée, 404 hors-site, password OK/KO + cookie, expiry 410, noindex meta, sitemap, stats +1/jour, domaine custom (Host override), form GET anonyme, POST valide → ligne, POST invalide → 400, rate-limit 429, honeypot 400, embed `?embed=1`, notif créée, ACL (non-owner 403/404).
## 7. Rollout
1. Migration 24 + CRUD API + tests. 2. Rendu `/s/` + gating + SEO. 3. Forms + embed + notifs. 4. UI Share/Site + docs `/help` + OpenAPI.
+69
View File
@@ -0,0 +1,69 @@
# V6.9.0 — Recherche sémantique + Ask AI
> **Statut** : ✅ Livré en v6.9.0 (2026-09-28) — `app/services/semantic_search.py`, `app/routers/search_ai.py`, migration 25, 24 tests verts · **Roadmap** : `ROADMAP.md § v6.9.0`
> Note d'implémentation : table générique `semantic_embeddings(resource_type, resource_id, …)` au lieu de `page_embeddings(page_id, …)` — couvre aussi les collections ; encodeur hashed-TF `hash-256` (pas d'appel `/embeddings` externe) ; pièces jointes et onglets palette en follow-up.
> **Référence Notion** : Enterprise Search (Notion + Slack/Jira/Drive) + AI Q&A avec citations.
> **Existant** : FTS5 (`services/search.py`, `GET /api/search`, `GET /api/v2/search`), palette `Ctrl+K`, `AgentEngine` (ReAct 12 tours), `PermissionManager`, `llm_config` + `LLMClient` (9 providers).
---
## 1. Vision
`question → top-k chunks autorisés → réponse avec [[fdpage:ID]] cliquables`.
Hybride lexical + vectoriel (RRF), jamais de fuite ACL (filtrage avant prompt).
Hors scope : index temps réel < 1s, connecteurs Slack/Jira (v7.x si besoin forge d'abord).
## 2. Indexation
### 2.1 Schéma (migration 25)
```sql
CREATE TABLE page_embeddings (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
chunk_id INTEGER NOT NULL, -- index du chunk (0..N)
chunk_text TEXT NOT NULL,
embedding BLOB NOT NULL, -- float32 serialisé
model TEXT NOT NULL DEFAULT 'local-tfidf',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_id, chunk_id)
);
CREATE TABLE search_index_state (page_id INTEGER PRIMARY KEY, indexed_at TIMESTAMP);
ALTER TABLE pages ADD COLUMN search_excluded BOOLEAN NOT NULL DEFAULT 0;
```
- Chunking : blocs → texte brut (~500 tokens, overlap 50), ignore `embed`/images (légende seule).
- Providers : `openai-compatible /embeddings` via `LLMClient` si clé user/workspace, sinon **TF-IDF local** (zéro dépendance, FR+EN stopwords) — même interface `embed(texts) -> vectors`.
- Job : scheduler 5 min, batch 50 (`updated_at > indexed_at`), `DELETE` embeddings si page trashée/exclue.
### 2.2 Contenus indexés
Pages `blocks` + `markdown`/`file` (résolution déjà `export.py`), pièces jointes texte (pdf via `pypdf`, docx via `python-docx`, txt/md — cap 1 MB/fichier), titres de lignes DB (sans valeurs privées si `property_permissions` restreint → chunk « titre seul »), issues Gitea/GitHub liées (titre + labels, pas de body privé sans grant).
## 3. Recherche hybride
`GET /api/v2/search/hybrid?q=&workspace_id=&type=&limit=20` :
1. FTS5 score (existants) ; 2. cosine top-50 ; 3. fusion RRF `k=60` ; 4. filtre ACL (`can_view_page`/`can_view_collection`) ; 5. `X-Total-Count`.
Filtres : `verified:1`, `type:page|collection|file`, `after:YYYY-MM-DD`.
Palette `Ctrl+K` : onglets `Pages / Fichiers / ✨ Réponses IA`.
## 4. Ask AI
`POST /api/v2/search/ask {question, workspace_id}` → `{answer_markdown, citations: [{page_id, title}]}` :
- top-k=8 chunks autorisés → prompt `system: réponds en français, cite [[fdpage:ID]]` → `LLMClient.chat()` (clé user, sinon mock déterministe « hors-ligne » comme `ai_writing.py`).
- Budget : 4k tokens contexte, timeout 60s, cache `(hash(question)+workspace)` 10 min.
- Citations résolues via `wiki/titles` (renommage propagé, « Deleted page » gérée).
## 5. UI / perfs / sécu
- Badge `✨` + temps de réponse, feedback 👍/👎 (`ask_feedback` log, futur fine-tune).
- ACL avant LLM (jamais de chunk interdit dans le prompt) ; `search_excluded` respecté partout ; audit `api_audit_log`.
- Perfs : embeddings lazy (pas au save, job fond), cosine en numpy si dispo sinon pur Python (DB < 10k pages OK).
## 6. Tests (`tests/test_v69_search_ask.py`, ~20)
Index chunks, hybride RRF ordre, ACL (page restricted exclue), `verified` filtre, exclusion flag, pièces jointes cap, ask citations valides, ask sans clé → fallback, cache hit, rate-limit, `X-Total-Count`.
## 7. Rollout
1. `embed()` + TF-IDF + job. 2. `/hybrid` + palette. 3. `/ask` + cache + feedback. 4. Docs `/help` + OpenAPI.
+16 -14
View File
@@ -1,6 +1,6 @@
# V6.0.0 — SSO / SAML : Enterprise Authentication
> **Statut** : Conception détaillée — v6.0.0
> **Statut** : ✅ **Livré en v6.7.0** (2026-09-24) — conception historique ci-dessous
> **Date** : 2026-09-15
> **Route** : `feat/v6-sso-saml` → `develop` → `main`
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant)
@@ -181,7 +181,7 @@ SSO_PROVIDER=saml
SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout
SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..."
SSO_X509_CERTIFICATE="-----BEGIN CERTIFICATE-----\n..."
SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
SSO_AUTO_PROVISION=true
SSO_DEFAULT_WORKSPACE_ID=1
@@ -432,18 +432,20 @@ ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
## 9. Checklist d'implémentation
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users`
5. **`services/sso_provisioning.py`** — auto-provision + group mapping
6. **Extension `settings.html`** — UI admin SSO
7. **Extension `login.html`** — boutons SSO
8. **SP metadata endpoint** (`/auth/saml/metadata`)
9. **Security** — validation assertions, rate limiting, audit log
10. **Tests** — tous les scénarios SSO
11. **Documentation utilisateur** — `/help` section SSO setup
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt
> ✅ **Tout est livré en v6.7.0** (`tests/test_v67_sso.py`, 38 tests) :
1. ✅ **`auth/providers/saml_provider.py`** — wrapper python3-saml (`app/auth/providers/saml_provider.py`)
2. ✅ **`auth/providers/oidc_provider.py`** — wrapper OIDC authlib (`app/auth/providers/oidc_provider.py`)
3. ✅ **`routers/sso.py`** — endpoints SSO/OIDC (`app/routers/sso.py` + API admin `/api/v2/sso/*`)
4. ✅ **Migration 23 `sso_config`** + `sso_login_history` + `sso_requests` + colonne `auth_method` sur `users` (déjà présente)
5. ✅ **`services/sso_provisioning.py`** — auto-provision + group mapping
6. ✅ **Extension `settings.html`** — UI admin SSO (onglet « SSO / Enterprise »)
7. ✅ **Extension page de login** — boutons SSO (nom dynamique)
8. ✅ **SP metadata endpoint** (`/auth/saml/metadata`)
9. ✅ **Security** — validation assertions (sign/aud/dest/InResponseTo), anti-replay `sso_requests`, rate limit login, historique
10. ✅ **Tests** — `tests/test_v67_sso.py` : 38 scénarios (flots SAML/OIDC, négatifs, groupes, sso_only, SLO)
11. ✅ **Documentation utilisateur** — `/help` section « SSO (Enterprise) »
12. ✅ **Dépendance** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0` dans requirements.txt
---
+71
View File
@@ -0,0 +1,71 @@
# V7.0.0 — Automations v2 + Workers
> **Statut** : ✅ Livré en v7.0.0 (2026-09-28) — `automation_steps` + 4 nouvelles actions + `services/workers.py` + `routers/workers.py`, migration 26, 31 tests verts · **Roadmap** : `ROADMAP.md § v7.0.0`
> Notes d'implémentation : legacy sans steps = fallback inchangé (pas de migration de données) ; `delay` = sleep plafonné 300s (pas de re-queue) ; `agent_trigger` = run synchrone (timeout `agent_run_timeout_seconds`) ; éditeur visuel canvas en follow-up.
> **Référence Notion** : Database automations (multi-triggers any/all, chaînes) + Buttons + Workers (custom code, credits dashboard, partage équipe, 07/2026).
> **Existant** : `automations` + `automation_runs` (scheduler 60s), triggers `page.*/collection.*` + cron + bouton, conditions `eq/neq/contains/...`, actions `webhook/set_property/create_page/notify`, bloc `button`, webhooks v2 HMAC + retry.
---
## 1. Vision
De `si X alors Y` à `quand (A ou B) et si C alors [Y1 → délai → Y2]`, plus `code custom` sandboxé :
`trigger(s) → conditions → steps → runs avec logs par step`.
## 2. Modèle (migration 26)
```sql
CREATE TABLE automation_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- trigger|condition|delay|action
position INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}'
-- trigger: {event: page.created|form.submitted|meeting.summarized|site.viewed|...,
-- mode: any|all (au niveau automation)}
-- condition: {property, operator, value} (AND entre steps condition)
-- delay: {seconds|minutes|hours|days}
-- action: {type: webhook|set_property|create_page|notify|slack|email|forge_issue|agent_trigger,
-- ...params + interpolation [[prop]]/{{title}}}
);
CREATE TABLE workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL, code_py TEXT NOT NULL DEFAULT '',
schedule_cron TEXT, shared BOOLEAN NOT NULL DEFAULT 0,
daily_budget_s INTEGER NOT NULL DEFAULT 60,
created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE worker_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- ok|error|timeout|over_budget
logs TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE collection_properties ADD COLUMN button_automation_id INTEGER REFERENCES automations(id);
```
Compat : automations v5.1.0 existantes migrées en 1 step trigger + 1 step action.
## 3. Sémantique d'exécution
- `mode any` (défaut) : n'importe quel step trigger déclenche ; `all` : tous les events doivent arriver dans la fenêtre 5 min (état `automation_runs`).
- Nouveaux triggers : `form.submitted` (v6.8), `meeting.summarized` (v7.1), `site.viewed` (seuil 100 vues/jour), `agent.run.failed`.
- Actions : `slack` (incoming webhook URL chiffrée), `email` (via `mailer.py`), `forge_issue` (Gitea/GitHub `owner/repo`, titre/body/labels), `agent_trigger` (`agent_id` + input), `delay` (re-queue run, pas de sleep).
- Scheduler existant étendu : évalue steps en ordre, log `automation_runs` enrichi `{step_id, in, out, ms}`.
## 4. Bouton DB natif
Propriété `button` : `{label, automation_id}` ; rendu cliquable en table/board/gallery ; `POST /api/automations/{id}/run` (déjà CSRF-exempt) + garde ACL écriture ligne ; optimistic toast.
## 5. Workers lite
- Code Python restreint : pas d'import réseau/`os`/`subprocess`, timeout 30s, mémoire cap, allowlist `httpx` vers `Gitea/GitHub` + webhooks sortants uniquement.
- Déclenchement : cron (`schedule_cron`) ou manuel ou `agent_trigger` ; `shared=1` → fork 1-clic (comme Notion Workers partagés).
- Compteur usage : `SUM(duration_ms)/jour/workspace` affiché façon credits dashboard.
## 6. UI / sécu / tests
- Settings → Automations : canvas vertical triggers → conditions → actions (Alpine.js, composants `components.css`), test-run avec payload sample, historique par step repliable.
- Secrets (Slack URL, tokens) chiffrés Fernet comme `sso_config` ; champ vide = conserver.
- Tests `tests/test_v70_automations_workers.py` (~25) : any/all, chaîne + delay (clock injectée), slack/email/forge/agent mocks, button ACL, worker timeout/over_budget/partage, migration v5 compat.
+43
View File
@@ -0,0 +1,43 @@
# V7.1.0 — Calendar sync + Meeting Notes
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Roadmap** : `ROADMAP.md § v7.1.0`
> Notes d'implémentation : pas de lib CalDAV/Google (REST + XML bruts) ; refresh OAuth hors scope (relink explicite sur 401) ; conflit = LWW auto + notif (pas de picker « garder ma version ») ; free/busy au jour (pas de créneaux horaires).
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026).
> **Existant** : vues jour/semaine/mois, moteur `recurrence.py`, `reminders.py` (scan 60s, dédup), timezones, `notify_assignment()`, template Meeting Notes.
---
## 1. Sync externe bidirectionnelle
```sql
CREATE TABLE calendar_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- google|caldav
tokens_enc TEXT NOT NULL DEFAULT '', -- Fernet (refresh + access)
calendar_id TEXT NOT NULL DEFAULT 'primary',
sync_token TEXT DEFAULT '',
last_sync TIMESTAMP, UNIQUE(user_id, provider, calendar_id)
);
ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT;
CREATE TABLE meeting_transcripts (
id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
audio_path TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '', language TEXT DEFAULT 'fr',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
- OAuth Google (`app/auth/providers/google_calendar.py`, scope `calendar.events`) + CalDAV générique (URL/user/pass chiffrés).
- Pull 15 min : event externe → page (titre + `date` + `__external_id__`) ; push : occurrence FlowDeck → event (si `sync_enabled` par collection).
- Conflit (modifié des 2 côtés depuis `last_sync`) → LWW + notif `calendar.conflict` + lien « garder ma version / prendre externe ».
- `GET /db/{id}/calendar/freebusy?attendees=&from=&to=` : créneaux libres (jour ouvré 9h-18h, fuseau user).
## 2. Meeting Notes v2
Upload `.mp3/.wav/.m4a` (100 MB) sur page Meeting → `meeting_transcripts` → transcription (Whisper local si binaire présent, sinon API via `llm_config`, timeout 10 min, job fond + polling) → résumé `ai_writing.py` (`Décisions`, `Next steps [[person]]`, `Action items`) inséré en blocs + `Agenda/Notes` existants complétés.
Émet `meeting.summarized` → automations v7.0 (update tracker, post Slack, file tickets).
## 3. Tests (`tests/test_v71_calendar_meetings.py`, ~15)
OAuth mock (tokens chiffrés roundtrip), pull/push, conflit LWW + notif, freebusy, transcription mock → résumé blocs, trigger émis, quota 100 MB refusé.
+35
View File
@@ -0,0 +1,35 @@
# V7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI
> **Statut** : 📝 Planifié · **Roadmap** : `ROADMAP.md § v7.2.0`
> **Référence Notion** : SCIM, audit log, domain verification, agent permissions/governance.
> **Existant** : SSO SAML/OIDC + provisioning + `sso_only` (v6.7.0), `api_audit_log` + `permission_audit_log` + `sso_login_history` (API seule), `SessionManager`, `PermissionManager`.
---
## 1. SCIM 2.0 (provisioning auto)
`GET|POST /scim/v2/Users`, `GET|PUT|PATCH|DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, admin) :
mapping `userName→login`, `emails[0]→email`, `name→full_name`, `active→users.is_active`.
`active=false` → `is_active=0`, sessions révoquées, login refusé (401 « suspended ») sauf admin.
Groupes IdP → `sync_sso_permissions()` existant réutilisé.
## 2. 2FA + passkeys
- TOTP : `users.totp_secret_enc` (Fernet), setup QR (`otpauth://`), vérif au login après password (fenêtre ±1), 10 backup codes (`sha256`, usage unique).
- WebAuthn : `webauthn_credentials (id, user_id, credential_id, public_key, sign_count)` ; login `password + key` ou `passkey seule` si `passwordless_allowed`.
- Combinable avec `sso_only` (local password refusé, TOTP conservé si `require_2fa=1`).
## 3. Domain claim + audit UI
- `domain_claims (domain UNIQUE, txt_token, verified BOOL, auto_join_role, enforce_sso BOOL)` ; vérif DNS TXT ; `enforce_sso` → login local du domaine redirigé IdP.
- Settings → Audit : table unifiée (`api_audit_log` + `permission_audit_log` + `sso_login_history` + `worker_runs`), filtres acteur/ressource/date, export CSV (10k max), rétention 365j (purge scheduler).
## 4. Gouvernance agents
`agent_policies (workspace_id, allowed_tools_json, max_steps, require_approval BOOL)` + `agent_approvals (action_id, status pending/approved/rejected, approver_id)` :
si `require_approval` et outil write → `agent.run.approval_requested` (webhook + cloche) → exécution après approve, sinon 403. File Settings → Agents.
## 5. Migrations 28 + tests (~25)
`scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, `users.is_active/totp_secret_enc`.
Tests : SCIM CRUD + suspend, TOTP ±fenêtre + backup, WebAuthn mock, domain TXT mock, audit filtres/export, policies approve/reject.
+72
View File
@@ -0,0 +1,72 @@
# V7.3.0 — Wiki / Teamspaces + Polish
> **Statut** : ✅ Implémenté (2026-09-29) — 72 tests · suite complète **1016 verts** · **Roadmap** : `ROADMAP.md § v7.3.0`
> **Référence Notion** : Wikis + Teamspaces + Verified pages + Charts/Number + réactions/follow/guests + page analytics.
> **Existant** : workspaces + rôles, backlinks, wiki-links `[[`, `collection_dashboards`, Chart.js, comments, `site_views`.
---
## 1. Teamspaces + Verified
```sql
CREATE TABLE teamspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL, private BOOLEAN NOT NULL DEFAULT 0, UNIQUE(workspace_id, name));
CREATE TABLE teamspace_members (teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'editor', UNIQUE(teamspace_id, user_id));
ALTER TABLE pages ADD COLUMN teamspace_id INTEGER REFERENCES teamspaces(id);
ALTER TABLE collections ADD COLUMN teamspace_id INTEGER REFERENCES teamspaces(id);
CREATE TABLE page_verifications (
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
verified_by INTEGER REFERENCES users(id), note TEXT DEFAULT '',
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP);
```
- `private=1` sans membership → 404 (comme collections restricted) ; `PermissionManager.get_teamspace_role()`.
- Badge ✅ (owner/editor vérifie, expiry 90j défaut, job notif owner à J-7) ; filtre `verified:1` en search + palette ; page `/wiki` auto (verified + recents + teamspaces).
## 2. Blocs & charts
- Bloc `mermaid` : code → SVG serveur si `mmdc` présent, sinon fallback bloc code + rendu client (déjà Prism) ; export HTML/PDF embarque le SVG.
- `equation_inline` : KaTeX inline (lib déjà self-hostée `static/js/katex.min.js`).
- Bloc `progress` : `{rollup_ref}` → barre % (réutilise `RollupEngine`).
- Charts : type `number` (big KPI), dashboards multi-DB en page (grille `collection_dashboards` + source `any`, limite 200 groupes documentée), « ouvrir la ligne » depuis chart.
- **Livré (follow-up)** : `number` = KPI `count|sum|avg|min|max` (`_chart_aggregate`/`_fmt_number`) ; rendu grille `GET /db/{collection_id}/dashboards/{dashboard_id}` — widgets à `collection_id` (= source `any`), ≤ 40 widgets, ≤ 200 lignes/chart (`CHART_MAX_GROUPS`) ; la config de vue est choisie selon le `view_type` demandé (bug corrigé : la première vue était toujours utilisée) ; un widget pointant sur une DB restreinte est sauté (pas de leak).
## 3. Collab polish
- `comment_reactions (comment_id, user_id, emoji, UNIQUE)` + picker emoji.
- `page_follows (page_id, user_id)` → notif `page.updated` aux followers (pref `follows`, défaut ON si commenté).
- **Livré (follow-up)** : `wiki.notify_followers_of_page_update` (throttle 10 min, `actor_id` exclu) branché dans `automations.fire_event` ; `actor_id` dans les payloads `board.update_page`/`save_page_blocks` ; auto-follow à la création d'un commentaire (`wiki.ensure_follow`).
- Guests : `guest_shares (page_id, email, token, role viewer|commenter, expires_at)` — accès sans compte via `/g/<token>` (session guest limitée, auditée).
- `page_views (page_id, day, views PK)` — même pattern `site_views`, exposé `GET /board/api/pages/{id}/views` (owner only).
## 4. Tests (`tests/test_v73_wiki_polish.py`, 72 ✅)
Teamspace private 404/grant, verify badge/expiry/notif, `/wiki` home, blocs
mermaid/equation/progress + export, réactions, follow, guest token/expiry,
page views. **Follow-ups** : sidebar cross-workspace (sans `workspace_id`),
page HTML `/wiki/teamspaces/{id}` (owner 200 / outsider 404 API + non-leak
HTML), auto-follow par commentaire, notif `page.updated` throttlée + acteur
exclu, `ensure_follow` idempotent, regex + unfurl gitea/github (tokens
mockés) + endpoint `/board/api/og/metadata`, KPI `number` + `_chart_values`
(0 conservé) + dashboards multi-DB + 404.
## 5. Implémentation réelle vs. design initial
- **Livré** : schema §1 à l'identique ; routes sous `/api/v2/wiki/*` (le design
parlait de `/wiki` comme page HTML : l'agrégat est exposé en JSON, le
front Tri-state reste à faire) ; blocs §2 (`mermaid`, `equation_inline`,
`progress`) rendus **côté serveur** par `app/services/wiki_blocks.py` et
branchés sur `export.blocks_to_html` ; collab §3 complet.
- **Follow-ups livrés** : sidebar par teamspace (section `base.html` +
listing cross-workspace + page HTML), charts `number` + dashboards multi-DB,
notif `page.updated` déclenchée à l'édition + auto-follow, unfurl
`gitea:`/`github:` dans `POST /board/api/og/metadata` (champs bookmark
persistés), UI Settings → Audit branchée sur `/api/v2/audit/logs`. Les
21 casses SSO (`test_v67_sso.py`) sont corrigées (install
`python3-saml==1.16.0` + `authlib==1.8.0`).
- **Écart assumé** : `progress` agrège directement sur
`collection_pages.property_values_json` plutôt que via `RollupEngine`, dont
la signature exige une chaîne de relations + une page source — ce qu'un bloc
de progression embarqué dans une page n'a pas.
+3595 -47
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
../@playwright/test/cli.js
-1
View File
@@ -1 +0,0 @@
../playwright-core/cli.js
-50
View File
@@ -1,50 +0,0 @@
{
"name": "e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-5
View File
@@ -1,5 +0,0 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
-318
View File
@@ -1,318 +0,0 @@
# 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-153.0.8010.12-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-155.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.6-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
## Get Started
Choose the path that fits your workflow:
| | Best for | Install |
|---|---|---|
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
---
## Playwright Test
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
### Install
```bash
npm init playwright@latest
```
Or add manually:
```bash
npm i -D @playwright/test
npx playwright install
```
### Write a test
```TypeScript
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
```
### Run tests
```bash
npx playwright test
```
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
### Key capabilities
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
**Locators.** Find elements with resilient locators that mirror how users see the page:
```TypeScript
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByPlaceholder('Search...')
page.getByTestId('login-form')
```
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
```TypeScript
// Save state after login
await page.context().storageState({ path: 'auth.json' });
// Reuse in other tests
test.use({ storageState: 'auth.json' });
```
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
```TypeScript
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});
```
```bash
npx playwright show-trace trace.zip
```
<!-- TODO: screenshot of trace viewer -->
**Parallelism.** Tests run in parallel by default across all configured browsers.
[Full testing documentation](https://playwright.dev/docs/intro)
---
## Playwright CLI
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
### Install
```bash
npm install -g @playwright/cli@latest
```
Optionally install skills for richer agent integration:
```bash
playwright-cli install --skills
```
### Usage
Point your coding agent at a task:
```
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
Take screenshots for all successful and failing scenarios.
```
Or run commands directly:
```bash
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
playwright-cli type "Buy groceries"
playwright-cli press Enter
playwright-cli screenshot
```
### Session monitoring
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
```bash
playwright-cli show
```
<!-- TODO: screenshot of playwright-cli show dashboard -->
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
---
## Playwright MCP
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
### Setup
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
```
**One-click install for VS Code:**
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
**For Claude Code:**
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
### How it works
Ask your AI assistant to interact with any web page:
```
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
```
The agent sees the page as a structured accessibility tree:
```
- heading "todos" [level=1]
- textbox "What needs to be done?" [ref=e5]
- listitem:
- checkbox "Toggle Todo" [ref=e10]
- text: "Buy groceries"
```
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
---
## Playwright Library
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
### Install
```bash
npm i playwright
```
### Examples
**Take a screenshot:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'screenshot.png' });
await browser.close();
```
**Generate a PDF:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.pdf({ path: 'page.pdf', format: 'A4' });
await browser.close();
```
**Emulate a mobile device:**
```TypeScript
import { chromium, devices } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext(devices['iPhone 15']);
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'mobile.png' });
await browser.close();
```
**Intercept network requests:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
await page.goto('https://playwright.dev/');
await browser.close();
```
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
---
## VS Code Extension
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
---
## Cross-Browser Support
| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->153.0.8010.12<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.6<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->155.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
## Other Languages
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
## Resources
* [Documentation](https://playwright.dev)
* [API reference](https://playwright.dev/docs/api/class-playwright)
* [MCP server](https://github.com/microsoft/playwright-mcp)
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
* [Contribution guide](CONTRIBUTING.md)
* [Changelog](https://github.com/microsoft/playwright/releases)
* [Discord](https://aka.ms/playwright/discord)

Some files were not shown because too many files have changed in this diff Show More