Compare commits

...
12 Commits
Author SHA1 Message Date
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
305 changed files with 11538 additions and 354036 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/
+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/
+398
View File
@@ -1,5 +1,403 @@
# Changelog - FlowDeck
## 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
+205 -16
View File
@@ -942,7 +942,7 @@ 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)
@@ -954,6 +954,105 @@ Détails livrés :
---
## 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)
---
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note |
@@ -963,21 +1062,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
@@ -996,4 +1107,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.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.*
*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**.*
- [ ] **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** : `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**.*
- [ ] **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
- [ ] **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**.*
- [ ] **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**.*
- [ ] **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**.*
- [ ] **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**.*
- [ ] **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**.*
- [ ] **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.**
+1 -1
View File
@@ -1 +1 @@
6.7.0
7.3.6
+11 -2
View File
@@ -1,7 +1,7 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0)
> **Début**: 2026-07-08 | **Version**: v7.3.6 (audit sécurité — A19 terminé) | **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
@@ -28,6 +28,15 @@
| **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
+65 -11
View File
@@ -40,19 +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(
@@ -66,15 +75,27 @@ logger = logging.getLogger(__name__)
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!")
# 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:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
(admin_hash,)
)
conn.commit()
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
@@ -100,6 +121,14 @@ async def lifespan(_app: FastAPI):
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = asyncio.create_task(semantic_index_scheduler())
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
from app.services.calendar_sync import calendar_sync_scheduler
calendar_task = asyncio.create_task(calendar_sync_scheduler())
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None
@@ -110,7 +139,7 @@ async def lifespan(_app: FastAPI):
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:
@@ -124,7 +153,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.7.0",
version="7.3.6",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -173,6 +202,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")
@@ -262,17 +302,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", "/auth/saml", "/auth/oidc", "/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
+453
View File
@@ -985,6 +985,459 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
)
@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.
+24 -20
View File
@@ -5,7 +5,7 @@ import json
import logging
from datetime import 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]] = {}
@@ -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))
+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})
+101
View File
@@ -318,12 +318,32 @@ async def local_login(request: Request):
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"]}})
@@ -458,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."""
+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
+89 -25
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
@@ -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 ""
@@ -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}
@@ -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.
@@ -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()
+6
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']}”"
+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>
""")
+113 -53
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
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
@@ -252,10 +252,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 +282,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 +310,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 +434,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 +448,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(
@@ -656,8 +653,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:
@@ -689,11 +686,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()
@@ -702,13 +708,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"}
@@ -717,7 +728,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(
@@ -730,7 +741,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)
@@ -755,14 +766,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"))
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
@@ -807,8 +818,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,
@@ -820,8 +831,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:
@@ -839,8 +850,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:
@@ -901,9 +912,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
@@ -1016,8 +1036,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:
@@ -1138,9 +1158,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 "
@@ -1161,13 +1193,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 "
@@ -1515,7 +1549,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)
@@ -1532,7 +1567,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 = []
@@ -1553,10 +1593,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"
@@ -1588,7 +1632,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)
@@ -1610,7 +1655,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 = []
@@ -1669,9 +1719,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', ?)""",
@@ -1721,8 +1775,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:
@@ -1824,8 +1878,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:
@@ -1873,7 +1927,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)
@@ -2142,9 +2203,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:
@@ -2180,7 +2240,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) }}"
@@ -2201,7 +2261,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(
@@ -2221,7 +2281,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 = ""
+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>"}
+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")}
+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},
}
+833
View File
@@ -0,0 +1,833 @@
"""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 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,
)
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:
pass
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:
pass
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:
password = ""
else:
try:
form = await request.form()
password = form.get("password", "")
except Exception:
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:
pass
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:
pass
try:
from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception:
pass
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"]
+2 -3
View File
@@ -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:
+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")}
+64 -10
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()
@@ -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())
+421
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import 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') ═══════════
@@ -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.debug("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)
+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.debug("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)
+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
+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)
+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()
+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) {}
+83 -30
View File
@@ -810,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 -->
@@ -1203,6 +1225,7 @@ function settingsInit() {
adminUsers: [],
adminStats: {},
auditEntries: [],
auditLogs: [],
showCreateUser: false,
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
@@ -1314,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(); }
@@ -1322,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();
@@ -1330,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();
},
@@ -1352,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;
@@ -1394,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() {
@@ -1595,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();
@@ -1624,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();
@@ -1660,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();
@@ -1686,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 = '';
@@ -1846,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();
@@ -1857,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 = ''; }
@@ -2002,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 = []; }
@@ -2012,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;
+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"]),
)
+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.
+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.
-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)
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
const { program } = require('playwright/lib/program');
program.parse(process.argv);
-18
View File
@@ -1,18 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
export * from 'playwright/test';
export { default } from 'playwright/test';
-17
View File
@@ -1,17 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
module.exports = require('playwright/test');
-18
View File
@@ -1,18 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
export * from 'playwright/test';
export { default } from 'playwright/test';
-35
View File
@@ -1,35 +0,0 @@
{
"name": "@playwright/test",
"version": "1.63.0",
"description": "A high-level API to automate web browsers",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=20"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.js",
"default": "./index.js"
},
"./cli": "./cli.js",
"./package.json": "./package.json",
"./reporter": "./reporter.js"
},
"bin": {
"playwright": "cli.js"
},
"scripts": {},
"dependencies": {
"playwright": "1.63.0"
}
}
-17
View File
@@ -1,17 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
export * from 'playwright/types/testReporter';
-17
View File
@@ -1,17 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
// We only export types in reporter.d.ts.
-17
View File
@@ -1,17 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
// We only export types in reporter.d.ts.
-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).
-3
View File
@@ -1,3 +0,0 @@
# playwright-core
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
-13
View File
@@ -1,13 +0,0 @@
microsoft/playwright-core
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
This package bundles third-party software inside individual files under
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
to it listing every npm package whose source was inlined into that
bundle, together with the full license text for each.
For example:
- lib/utilsBundle.js.LICENSE
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
-5
View File
@@ -1,5 +0,0 @@
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
# check if running on Windows Server
if ($osInfo.ProductType -eq 3) {
Install-WindowsFeature Server-Media-Foundation
}
-33
View File
@@ -1,33 +0,0 @@
$ErrorActionPreference = 'Stop'
# This script sets up a WSL distribution that will be used to run WebKit.
$Distribution = "playwright"
$Username = "pwuser"
$distributions = (wsl --list --quiet) -split "\r?\n"
if ($distributions -contains $Distribution) {
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
} else {
Write-Host "Installing new WSL distribution '$Distribution'..."
$VhdSize = "10GB"
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
}
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
$initScript = @"
if [ ! -f "/home/$Username/node/bin/node" ]; then
mkdir -p /home/$Username/node
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
fi
/home/$Username/node/bin/node cli.js install-deps webkit
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
"@ -replace "\r\n", "`n"
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
Write-Host "Done!"
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome-beta
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome beta from dl.google.com and install it.
cd /tmp
curl -L -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
apt-get install -y ./google-chrome-beta_current_amd64.deb
rm -rf ./google-chrome-beta_current_amd64.deb
cd -
google-chrome-beta --version
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome Beta.app"
cd /tmp
curl -L --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
hdiutil detach /Volumes/googlechromebeta.dmg
rm -rf /tmp/googlechromebeta.dmg
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
-24
View File
@@ -1,24 +0,0 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
Write-Host "Downloading Google Chrome Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome stable from dl.google.com and install it.
cd /tmp
curl -L -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt-get install -y ./google-chrome-stable_current_amd64.deb
rm -rf ./google-chrome-stable_current_amd64.deb
cd -
google-chrome --version
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome.app"
cd /tmp
curl -L --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
hdiutil detach /Volumes/googlechrome.dmg
rm -rf /tmp/googlechrome.dmg
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
-24
View File
@@ -1,24 +0,0 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome.msi"
Write-Host "Downloading Google Chrome"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-beta
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-beta
microsoft-edge-beta --version
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_beta.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_beta.pkg -target /
rm -rf /tmp/msedge_beta.pkg
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
-23
View File
@@ -1,23 +0,0 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old dev if any.
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-dev
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-dev
microsoft-edge-dev --version
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_dev.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_dev.pkg -target /
rm -rf /tmp/msedge_dev.pkg
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
-23
View File
@@ -1,23 +0,0 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Dev"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Dev"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-stable
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-stable
microsoft-edge-stable --version
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl -L --retry 3 -o ./msedge_stable.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_stable.pkg -target /
rm -rf /tmp/msedge_stable.pkg
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version

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