Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb47f5c7f4 | ||
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 | ||
|
|
5a537f5dc3 | ||
|
|
8ab6569974 | ||
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 | ||
|
|
1706ad1ee9 | ||
|
|
d074689b18 | ||
|
|
9562f30366 |
@@ -14,3 +14,10 @@ build/
|
||||
node_modules/
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# A9 — jamais de DB ni de fichiers de test dans l'image
|
||||
*.db
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
e2e/
|
||||
|
||||
+26
-2
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
|
||||
DEFAULT_LANG=fr
|
||||
|
||||
# ── Database ──
|
||||
# SQLite (default): sqlite:////data/flowdeck.db
|
||||
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
||||
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
|
||||
# schéma retombe silencieusement sur /data/flowdeck.db).
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
# ── Sync ──
|
||||
@@ -58,3 +58,27 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=FlowDeck <[email protected]>
|
||||
SMTP_USE_TLS=true
|
||||
APP_BASE_URL=http://localhost:8080
|
||||
|
||||
# ── SSO / Enterprise (v6.7.0) ──
|
||||
# Fallback de démarrage uniquement : dès qu'un admin enregistre une configuration
|
||||
# dans Settings → Admin → SSO / Enterprise, la table `sso_config` prime sur le .env.
|
||||
# Le bouton SSO n'apparaît sur la page de connexion que si une config est active.
|
||||
# SSO_PROVIDER=saml # saml | oidc (vide = SSO désactivé)
|
||||
# SSO_NAME=Company SSO # libellé du bouton
|
||||
# SSO_ONLY=false # true = refuser le login local (les admins gardent le leur)
|
||||
# SSO_AUTO_PROVISION=true # créer le compte au premier login SSO
|
||||
# SAML :
|
||||
# SSO_ENTITY_ID=https://idp.example.com/saml/metadata
|
||||
# SSO_SSO_URL=https://idp.example.com/saml/sso
|
||||
# SSO_SLO_URL=https://idp.example.com/saml/slo
|
||||
# SSO_X509_CERTIFICATE=-----BEGIN CERTIFICATE-----
|
||||
# SSO_SIGN_REQUESTS=false # signer les AuthnRequests / LogoutRequest
|
||||
# OIDC :
|
||||
# SSO_ISSUER_URL=https://auth.example.com/realms/flowdeck
|
||||
# SSO_CLIENT_ID=
|
||||
# SSO_CLIENT_SECRET=
|
||||
# SSO_SCOPE=openid profile email
|
||||
# Mapping (JSON) :
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -1709,5 +1709,7 @@ docker compose restart flowdeck
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py`
|
||||
- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py`
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+577
@@ -1,5 +1,582 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
|
||||
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
|
||||
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
|
||||
la valeur par défaut (il signe les sessions)
|
||||
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
|
||||
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
|
||||
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
|
||||
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
|
||||
(croissance mémoire bornée)
|
||||
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
|
||||
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
|
||||
scheduler passés en `warning`
|
||||
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
|
||||
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
|
||||
dupliqué retiré du ROADMAP
|
||||
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
|
||||
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
|
||||
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
|
||||
(format ISO naïf identique, zéro changement de comportement)
|
||||
|
||||
### Notes
|
||||
|
||||
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
|
||||
l'alignement à 3.13 implique un rebuild d'image à valider
|
||||
|
||||
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
|
||||
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
|
||||
d'événements/webhooks et des écritures sont enfin visibles dans les logs
|
||||
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
|
||||
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
|
||||
transaction au lieu de commiter une collection sans schéma
|
||||
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
|
||||
point d'entrée des connexions) ; le wrapper async + les 510 call sites
|
||||
synchrones sur l'event loop restent à migrer
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
|
||||
|
||||
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
|
||||
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
|
||||
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
|
||||
`ping()` du serveur vers un `api_base` interne
|
||||
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
|
||||
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
|
||||
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
|
||||
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
|
||||
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
|
||||
|
||||
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
|
||||
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
|
||||
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
|
||||
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
|
||||
préfixes sortent d'`EXCLUDED_PATHS`
|
||||
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
|
||||
(interpolations Jinja neutralisées) — 0 échec avant/après
|
||||
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
|
||||
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
|
||||
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
|
||||
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
|
||||
|
||||
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
|
||||
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
|
||||
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
|
||||
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
|
||||
`welcome.html` équipés du header
|
||||
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||
route, pas le 403 du middleware) → suite **1026/1026**
|
||||
|
||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
|
||||
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
|
||||
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
|
||||
couverte par A7
|
||||
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
|
||||
|
||||
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A12** — unfurl OG : `follow_redirects` manuel + `_is_public_host` à chaque
|
||||
saut → 400 vers loopback/link-local (ex. `169.254.169.254`)
|
||||
- **A13** — automations : `Depends(_require_session)` sur le router entier
|
||||
(CRUD, run, press-button) + action `webhook` validée avant POST
|
||||
- **A15** — webhooks sortants : admin exigé + URL publique (SSRF scheduler)
|
||||
- **A17** — router legacy `/api` : session ou Bearer (`/api/v1`) ; allowlist
|
||||
explicite `/api/health`, `/api/frontend-error`
|
||||
- **A22** — uploads locaux : session exigée, `validate_upload` branché (10 MB +
|
||||
extensions), `FLOWDECK_DATA_DIR` remplace le `/data` codé en dur
|
||||
- **A23** — N+1 : `GROUP BY` (compteurs de pages), `executemany` (cards de sync
|
||||
+ duplicata de propriétés avec remap d'ids vérifié)
|
||||
- **A24** — 2 routes silencieusement écrasées supprimées + test « aucun doublon
|
||||
méthode+chemin » sur les 680 routes
|
||||
|
||||
### Tests
|
||||
|
||||
- +9 non-régressions dans `tests/test_audit_p0_fixes.py` → suite **1025/1025**
|
||||
|
||||
## v7.3.2 (2026-09-30) — Audit sécurité : A11 + A18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A11** — `GET /api/settings/avatar/{filename:path}` : `resolve()` +
|
||||
`relative_to()` (le motif de `serve_uploaded_file`) → 403 hors `/data/avatars`
|
||||
- **A18** — `GET /workspace/public/{id}` : 404 explicite pour les bases
|
||||
`restricted`/`private` (`permission_type`) et `html.escape` sur nom, icône et
|
||||
titres de lignes — ce f-string HTML ne passe pas par Jinja2, donc l'autoescape
|
||||
A10 ne le couvrait pas
|
||||
- Tests : `tests/test_audit_p0_fixes.py` (3 non-régressions) — suite **1019/1019**
|
||||
|
||||
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
|
||||
|
||||
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
|
||||
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
|
||||
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
|
||||
cycle v6.8→v7.3 committé + tag `v7.3.0`
|
||||
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
|
||||
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
|
||||
sorti de la liste CSRF exemptée
|
||||
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
|
||||
chemin legacy `user_id=0` supprimé
|
||||
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
|
||||
global), placeholder user créé en `is_admin=0`
|
||||
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
|
||||
legacy single-user sur les collections)
|
||||
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
|
||||
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
|
||||
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
|
||||
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
|
||||
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
|
||||
rotation de `APP_SECRET_KEY`
|
||||
- **A10** — `app/templating.py` : un seul `ENV` avec
|
||||
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
|
||||
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
|
||||
en `|tojson`)
|
||||
|
||||
### Tests
|
||||
|
||||
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
|
||||
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
|
||||
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
|
||||
|
||||
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
|
||||
|
||||
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
|
||||
> expiration, guests sans compte, réactions, follows, analytics de page et
|
||||
> trois nouveaux blocs rendus côté serveur. Design : `docs/V73_Wiki_Teamspaces_Polish.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Teamspaces** — `app/services/wiki.py` + `app/routers/wiki.py` : `teamspaces`
|
||||
(`workspace_id`, `private`, `UNIQUE(workspace_id, name)`) et
|
||||
`teamspace_members` (rôles `owner`/`editor`/`commenter`/`viewer`) ; CRUD +
|
||||
listing par workspace, ajout/retour de membres ; `private=1` → **404** (pas
|
||||
403) pour les non-membres, comme les collections restricted ; un teamspace
|
||||
public reste cantonné au workspace (pas de `viewer` implicite pour un compte
|
||||
qui n'en est pas membre) ; `pages.teamspace_id` + `collections.teamspace_id`
|
||||
- **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`,
|
||||
`expires_at` 90 j par défaut, re-vérifier remplace) ; l'index
|
||||
`GET /api/v2/wiki/verified` exclut les badges expirés et les pages de
|
||||
teamspaces privés ; sweep `POST /api/v2/wiki/verify-expiry-sweep` (admin)
|
||||
notifie le vérificateur à J-7 (`page.verification_expiring`) ; la
|
||||
vérification exige un rôle editor/owner/admin, sinon 403
|
||||
- **Guests sans compte** — `guest_shares` (`token`, `role viewer|commenter`,
|
||||
`expires_at`, `revoked`) ; accès par `GET /g/{token}` **sans session**,
|
||||
enregistrement d'une vue, révocation idempotente ; page 404 HTML dédiée au
|
||||
lieu d'une redirection vers `/workspaces`
|
||||
- **Collab polish** — `comment_reactions` (agrégation par emoji + users,
|
||||
toggle), `page_follows` (toggle + followers), `page_views` (compteurs
|
||||
journaliers, séries sans trou via `view_stats`)
|
||||
- **Wiki Home** — `GET /api/v2/wiki/home` (teamspaces + verified + recents)
|
||||
- **Blocs** — `app/services/wiki_blocks.py` : `mermaid` (SVG inline si
|
||||
`mmdc` est installé, sinon `<pre class="mermaid">` rendu côté client),
|
||||
`equation_inline` (KaTeX, source sanitizée : `<`, `>`, `\` retirés pour
|
||||
empêcher la fermeture anticipée du délimiteur ou l'injection de balises),
|
||||
`progress` (agrégation directe sur `property_values_json` → barre %) ; les
|
||||
trois sont rendues par `export.blocks_to_html` (donc présentes dans
|
||||
l'export HTML/PDF) et exposées via `POST /api/v2/wiki/blocks/preview`
|
||||
- **Migration 29** — `teamspaces`, `teamspace_members`, `page_verifications`,
|
||||
`comment_reactions`, `page_follows`, `guest_shares`, `page_views` +
|
||||
colonnes `teamspace_id` sur `pages`/`collections`
|
||||
- **Sidebar teamspaces** — section `Teamspaces` dans `base.html` (état Alpine
|
||||
`teamspaces`/`loadTeamspaces`/`openTeamspace`, section ouverte par défaut,
|
||||
icône/label/ordre, fallback du panneau de personnalisation) →
|
||||
`GET /api/v2/wiki/teamspaces` accepte désormais l'omission de `workspace_id`
|
||||
(listing cross-workspace avec `workspace_name`), page HTML
|
||||
`GET /wiki/teamspaces/{id}` (pages + collections du teamspace, rôle affiché) ;
|
||||
entrée `teamspaces` dans `sidebar_config.DEFAULT_CONFIG`
|
||||
- **Notif `page.updated` aux followers** — `wiki.notify_followers_of_page_update`
|
||||
(une par 10 min, `actor_id` exclu) branchée dans `automations.fire_event` ;
|
||||
payloads `actor_id` ajoutés dans `board.update_page` et `board.save_page_blocks` ;
|
||||
commenter une page = suivre (auto-follow `wiki.ensure_follow`, défaut ON)
|
||||
- **Charts avancés** — type `number` (KPI) avec agrégats `count|sum|avg|min|max`
|
||||
dans `collections._render_chart` (+ `_chart_aggregate`/`_fmt_number`) ; le « 0 »
|
||||
n'est plus forcé à 1 ; les dashboards multi-DB se rendent via
|
||||
`GET /db/{collection_id}/dashboards/{dashboard_id}` (widgets `collection_id`,
|
||||
≤ 40 widgets, ≤ 200 lignes/chart) ; `view_collection` choisit maintenant la
|
||||
config de vue correspondant au `view_type` demandé
|
||||
- **Unfurl `gitea:`/`github:`** — `POST /board/api/og/metadata` résout les refs
|
||||
`gitea:owner/repo` / `github:owner/repo` via `GiteaClient.get_repo_info`
|
||||
(ajouté) ou `GitHubAdapter` (token optionnel, sinon API publique) sans
|
||||
télécharger la page ; champs bookmark `url/title/description/image/site_name`
|
||||
conservés dans l'autosave du bloc
|
||||
- **UI Settings → Audit** — `settings.html` : l'onglet `admin-audit` interroge
|
||||
désormais `/api/v2/audit/logs` (sources `api`/`permissions`/`sso`, filtres
|
||||
`actor`/`action`, pagination « Load more », export CSV)
|
||||
- **SSO 21 casses** — dépendances `python3-saml==1.16.0` + `authlib==1.8.0`
|
||||
(plus `xmlsec`/`isodate`/`joserfc`) installées → `test_v67_sso.py` **38/38**
|
||||
- **Sécurité** — `sanitize_equation` retire désormais `<`/`>`/`\` (pas de
|
||||
breakout KaTeX/markup), `revoke_guest` 404 fondé sur l'existence, pas le
|
||||
rowcount
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v73_wiki_polish.py` : **58 → 72 tests** (sidebar cross-workspace +
|
||||
page teamspace owner/outsider, auto-follow par commentaire, notif
|
||||
`page.updated` throttlée + acteur exclu, `ensure_follow` idempotent, regex +
|
||||
unfurl gitea/github + endpoint `/board/api/og/metadata`, KPI + `_chart_values`
|
||||
(0 conservé) + dashboards multi-DB + 404)
|
||||
- `ruff check app tests` OK · suite complète `python -m pytest -n auto` :
|
||||
**1016 passed** (était : 981 passed / 21 failed en SSO avant install des deps)
|
||||
|
||||
---
|
||||
|
||||
## v7.2.0 (2026-09-29) — Enterprise admin : SCIM 2.0, 2FA, Audit, gouvernance agents
|
||||
|
||||
> Le socle administration d'une instance auto-hébergée en équipe : provisionnement
|
||||
> SCIM depuis l'IdP, TOTP + passkeys, journal d'audit unifié et garde-fous
|
||||
> d'exécution pour les agents. Design : `docs/V72_Enterprise_SCIM_2FA.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **SCIM 2.0** — `app/routers/scim.py` : `GET/POST /scim/v2/Users`,
|
||||
`GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, schémas
|
||||
core:2.0:User, `active` → `users.is_active` + révocation `user_sessions`,
|
||||
`Operations` PATCH `active`/`userName`) ; tokens SHA-256 stockés, affichés
|
||||
une seule fois, révocables (`/api/v2/scim/tokens`) ; exempté CSRF (clients
|
||||
IdP sans cookie) et 404 `application/scim+json` au lieu d'une redirection
|
||||
- **TOTP 2FA** — `app/services/two_factor.py` : secret chiffré Fernet au repos,
|
||||
10 codes de secours à usage unique (SHA-256), Défi `pending` signé 5 min ;
|
||||
`POST /auth/local-login` renvoie `2fa_required` sans créer de session, puis
|
||||
`POST /auth/local-verify` l'échange contre une session ; setup/activate/
|
||||
disable/status côté utilisateur
|
||||
- **Passkeys WebAuthn** — `app/routers/webauthn.py` : enregistrement
|
||||
(`register/begin|finish`, attestation vérifiée, COSE stocké) et connexion
|
||||
**sans mot de passe** (`login/begin|finish`, anti-rejeu `sign_count`,
|
||||
vérif. origine/RP) ; listing + suppression des clés
|
||||
- **Domain claims** — `POST /api/v2/domain-claims` (normalisation lowercase,
|
||||
jeton `.well-known/flowdeck-verify.txt`), `POST .../verify` (fetch HTTPS du
|
||||
domaine + comparaison), `enforce_sso` qui bloque le login local par domaine
|
||||
dans `auth.local_login` (les admins gardent l'accès local) ; jeton jamais
|
||||
renvoyé par le listing
|
||||
- **Audit unifié** — `app/routers/audit.py` : `GET /api/v2/audit/logs` fusionne
|
||||
`api_audit_log` + `permission_audit_log` + `sso_login_history` en un schéma
|
||||
commun, filtres `source`/`actor`/`action`, pagination, export
|
||||
`?format=csv` (admin ou Bearer `read:admin`)
|
||||
- **Gouvernance des agents** — `app/services/agent_policies.py` + `app/routers/governance.py` :
|
||||
`agent_policies` (liste d'outils autorisés par workspace, `max_steps`,
|
||||
`require_approval`) consultée par `AgentEngine` **avant** les ACL, file
|
||||
`agent_approvals` pour les écritures, décision admin
|
||||
(`/api/v2/agent-approvals/{id}/decide`) + événement
|
||||
`agent.run.approval_requested`
|
||||
- **Migration 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`,
|
||||
`agent_policies`, `agent_approvals` + `users.totp_secret_enc`,
|
||||
`users.totp_backup_hashes`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Les 404 sur `/scim/v2` et `/auth/webauthn` renvoyaient une redirection 302
|
||||
vers `/workspaces` (handler global) : ils retournent désormais du JSON, et
|
||||
`/scim/v2` répond en `application/scim+json`
|
||||
- `/scim/v2` et les routes 2FA/WebAuthn ajoutées à la liste d'exclusion CSRF
|
||||
(authentification Bearer, pas de cookie de session)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v72_enterprise.py` : **52 tests** (migration 28, SCIM tokens/CRUD/
|
||||
suspend/duplicate/404, 2FA setup-activate-verify-backup-chiffrement-désactivation,
|
||||
challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV +
|
||||
pagination, politiques et gate d'approbation)
|
||||
- `ruff check app tests` OK · `tests/test_agent.py` + `tests/test_app.py` :
|
||||
**261 verts** (la gouvernance ne casse pas l'engine)
|
||||
|
||||
---
|
||||
|
||||
## v7.1.0 (2026-09-28) — Calendar sync + Meeting Notes
|
||||
|
||||
> Calendrier bidirectionnel Google/CalDAV + transcription → résumé IA qui
|
||||
> déclenche les agents (pattern Notion 07/2026). Design : `docs/V71_Calendar_Meetings.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sync bidirectionnelle** — `app/services/calendar_sync.py` : `calendar_links`
|
||||
(tokens Fernet, `collection_id`, `date_property`), pull (event → ligne datée +
|
||||
`external_event_id`) + push, boucle 15 min dans le lifespan ; conflits
|
||||
(édité des 2 côtés → last-write-wins + notif `calendar.conflict`) ; lignes
|
||||
touchées par le pull jamais repoussées ; API CRUD + `POST .../sync`
|
||||
(`app/routers/meetings.py`, session ou Bearer `write`, tokens jamais leakés)
|
||||
- **Sans dépendance** — Google Calendar REST (401 → « relink »), CalDAV brut
|
||||
(REPORT + parseur multistatus, PUT) ; I/O module-level monkeypatchables
|
||||
- **Meeting Notes v2** — `app/services/meetings.py` : upload audio 100 MB
|
||||
(mp3/wav/m4a/ogg/flac/aac), transcription `STT_COMMAND` ou transcript manuel,
|
||||
résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized`
|
||||
(branché automations v7.0) ; endpoints upload/texte/summarize
|
||||
- **Free/busy** — `GET /db/{id}/calendar/freebusy` (busy + free weekdays,
|
||||
récurrences expandues, 1..370 j)
|
||||
- **Migration 27** — `calendar_links`, `meeting_transcripts`,
|
||||
`collection_pages.external_event_id` + index
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v71_calendar_meetings.py` : **15 tests** (migration, links,
|
||||
pull/push/idempotence/conflit + notif, 502, CalDAV, freebusy, meetings ×5)
|
||||
- `ruff check app tests` OK · suite **871 verts** (21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v7.0.0 (2026-09-28) — Automations v2 + Workers lite
|
||||
|
||||
> Du if-this-then-that aux chaînes multi-triggers + custom code sandboxé,
|
||||
> parité Notion Automations + Workers. Design : `docs/V70_Automations_Workers.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Automations multi-étapes** — `automation_steps` (trigger/condition/delay/action
|
||||
ordonnés) : CRUD `GET/POST /workspace/automations/{id}/steps`,
|
||||
`PUT/DELETE /workspace/automations/steps/{id}` (session, validation serveur),
|
||||
`PUT .../mode` (`any` défaut / `all` fenêtre 5 min) ; conditions AND réutilisant
|
||||
`match_condition_props` ; `form.submitted` déclenchable ; legacy sans steps intact
|
||||
(matcher legacy ignore les automatisations à steps → pas de double run)
|
||||
- **Nouvelles actions** — `slack` (incoming webhook, URL chiffrée Fernet au repos,
|
||||
décryptée à l'exécution), `email` (`user:<id>` résolu ou reply-to créateur,
|
||||
repli propre sans SMTP), `forge_issue` (Gitea via `GiteaClient` / GitHub API,
|
||||
token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`),
|
||||
`delay` (0..86400s validé, sleep plafonné 300s) ; interpolation `[[prop]]` /
|
||||
`{{title}}` conservée ; backends module-level = monkeypatchables
|
||||
- **Bouton DB natif** — type `button` (`PROPERTY_TYPES`), `button_automation_id`,
|
||||
`POST /api/automations/press-button` (CSRF-exempt, 400 explicites)
|
||||
- **Workers lite** — `app/services/workers.py` (lint AST : imports réseau/OS,
|
||||
`open/exec/eval`, dunders ; builtins restreints ; thread + timeout 30s ;
|
||||
budget journalier ; fork des partagés) + `app/routers/workers.py`
|
||||
(CRUD `/api/v2/workers*` session/Bearer `write`, code masqué aux non-owners,
|
||||
run/runs/fork/usage) ; crons branchés sur la boucle scheduler 60s
|
||||
- **Migration 26** — `automation_steps`, `workers`, `worker_runs`,
|
||||
`automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v70_automations_workers.py` : **31 tests** (migration, steps,
|
||||
any/all, chaînes, delay, 4 nouvelles actions, secret chiffré, button,
|
||||
legacy, workers ×11)
|
||||
- `ruff check app tests` OK · suite **855 verts** (21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement ; 1 flaky parallèle
|
||||
`test_env_config_fallback…` qui passe isolé)
|
||||
|
||||
---
|
||||
|
||||
## v6.9.0 (2026-09-28) — Recherche sémantique + Ask AI
|
||||
|
||||
> Retrouver (hybride lexical + vectoriel) et demander (RAG avec citations),
|
||||
> parité Notion Enterprise Search + AI Q&A. Design : `docs/V69_Search_Ask_AI.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Moteur sémantique** — `app/services/semantic_search.py` : chunking chevauchant
|
||||
(1200 cars / overlap 150, récursif dans `children`), encodeur hashed-TF `hash-256`
|
||||
(md5 % 256, L2, déterministe, zéro dépendance, `embed_texts()` pluggable),
|
||||
cosinus pur Python, fusion RRF (k=60), job incrémental `index_pending()` (batch 50,
|
||||
scheduler 5 min dans le lifespan) + `purge_orphans()`
|
||||
- **Recherche hybride** — `GET /api/v2/search/hybrid` (`app/routers/search_ai.py`,
|
||||
session ou Bearer `read`) : lexical FTS5/LIKE + vecteurs, filtre `workspace_id`,
|
||||
scope membership, `PermissionManager` (pages `restricted` masquées),
|
||||
`search_excluded` respecté, pagination + `X-Total-Count`
|
||||
- **Ask AI** — `POST /api/v2/search/ask` : top-8 chunks autorisés (ACL **avant** prompt),
|
||||
`LLMClient.complete()` si provider configuré sinon extractif offline avec
|
||||
`[[fdpage:ID]]`, citations résolues (titres, « Deleted page » gérée), cache 10 min,
|
||||
rate-limit 30/min, `api_audit_log`
|
||||
- **Observabilité** — `GET /api/v2/search/index-status` (ressources, vecteurs, modèle)
|
||||
- **Migration 25** — `semantic_embeddings`, `semantic_index_state`,
|
||||
`pages.search_excluded`
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v69_search_ask.py` : **24 tests** (migration, chunk/overlap, déterminisme,
|
||||
cosinus, index idempotent, exclusion, purge, rappel partiel, hybride ×7, ask ×6,
|
||||
index-status)
|
||||
- `ruff check app tests` OK · suite **825 verts** (seuls 21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v6.8.0 (2026-09-28) — Sites & Forms publics
|
||||
|
||||
> Parité Notion Sites + Forms : publier un mini-site multi-pages et collecter
|
||||
> des réponses anonymes dans une database. Design : `docs/V68_Sites_Forms.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sites multi-pages** — `app/routers/sites.py` : CRUD `GET/POST/PATCH/DELETE /api/v2/sites`
|
||||
(session ou Bearer + scope `write`, pagination `X-Total-Count`, `api_audit_log`),
|
||||
`GET/POST/DELETE /api/v2/sites/{id}/pages` (arbre ordonné, racine protégée),
|
||||
`GET /api/v2/sites/{id}/stats` (vues jour + total) ; rendu public `GET /s/<slug>` +
|
||||
`GET /s/<slug>/<page-slug>` (nav latérale, thèmes light/dark, blocs/synced/wiki résolus)
|
||||
- **Gating & SEO** — mot de passe (`password_utils` salé, cookie signé 24h,
|
||||
`GET|POST /s/<slug>/auth`), expiry (410), `noindex`, OG/Twitter cards,
|
||||
`GET /s/<slug>/sitemap.xml`, domaine custom via header `Host`
|
||||
- **Forms publics** — `PUT/GET /api/v2/collections/{id}/form` (`enabled`, `public_token f_*`,
|
||||
`fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>`
|
||||
(formulaire no-auth, `?embed=1` sans chrome) + `POST /f/<token>` (anonyme :
|
||||
rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ;
|
||||
chaque soumission = `collection_pages` + `form_responses` (ip_hash jour, pas d'IP),
|
||||
notif in-app + trigger `form.submitted`
|
||||
- **Migration 24** — tables `sites`, `site_pages`, `site_views`, `form_responses`,
|
||||
colonne `collections.form_config_json`
|
||||
- CSRF exempte `/s/` + `/f/` (soumissions anonymes cross-site)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v68_sites_forms.py` : **20 tests** — migration 24, CRUD, slug/conflit,
|
||||
auth 401, isolation inter-users, pages add/remove, rendu home/subpage/blocs, 404,
|
||||
vues comptées, password gate, expiry 410, sitemap, noindex, form config/submit JSON,
|
||||
required 400, 404, honeypot, rate-limit 429, embed
|
||||
- `ruff check app tests` OK · pas de régression (801 verts ; seuls 21 échecs
|
||||
`test_v67_sso.py` pré-existants — dépendance `onelogin` absente de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
|
||||
|
||||
> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
|
||||
> d'entreprise (SAML 2.0 ou OpenID Connect + PKCE), auto-provisioning des
|
||||
> comptes, mapping des groupes de l'IdP vers les rôles workspace, mode
|
||||
> « SSO only » et configuration admin dans Settings. Design :
|
||||
> `docs/V6_SSO_SAML_Enterprise_Auth.md` (checklist §9 cochée).
|
||||
|
||||
### Added
|
||||
|
||||
- **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) :
|
||||
login SP-initié `GET /auth/saml/login` (RelayState = id AuthnRequest + jeton CSRF
|
||||
en DB), ACS `POST /auth/saml/callback` (signature, audience, destination,
|
||||
InResponseTo, anti-replay `sso_requests`), SP metadata `GET /auth/saml/metadata`,
|
||||
SLO `GET|POST /auth/saml/logout` (relais LogoutRequest/Response vers l'IdP) ;
|
||||
`allowSingleLabelDomains` activé pour les hôts homelab/LAN
|
||||
- **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) :
|
||||
`GET /auth/oidc/login` (`state` + `code_verifier` stockés en DB),
|
||||
`GET|POST /auth/oidc/callback` (code → token → userinfo ; ID token vérifié via
|
||||
JWKS : aud/iss/nonce/exp), discovery + JWKS mis en cache 1 h,
|
||||
`GET|POST /auth/oidc/logout` (`end_session_endpoint` si présent)
|
||||
- **Provisioning** — `app/services/sso_provisioning.py` : création automatique de
|
||||
l'utilisateur au 1er login (`auth_method=saml|oidc`, lien via email), rôle par
|
||||
défaut + `default_workspace_id`, mapping groupes IdP → rôles workspace
|
||||
(re-synchronisé à chaque login + `POST /api/v2/sso/sync`), mode **SSO only**
|
||||
(login local refusé sauf administrateurs — design §7.1), secrets chiffrés
|
||||
Fernet dans `sso_config` (champ secret vide = conserver l'existant), détection
|
||||
de rejeu via `sso_requests` (TTL 15 min), `sso_login_history` (succès + échecs),
|
||||
`safe_next_path` (pas de redirection externe)
|
||||
- **Routeur** — `app/routers/sso.py` : parcours navigateur + API
|
||||
`GET|POST|PUT|DELETE /api/v2/sso/config` (admin, header `X-CSRF-Token`,
|
||||
`audit_log`), `GET /api/v2/sso/providers` (public, boutons de la page de login),
|
||||
`GET /api/v2/sso/workspaces`, `POST /api/v2/sso/sync`, `GET /api/v2/sso/history` ;
|
||||
CSRF exclut les préfixes `/auth/saml` et `/auth/oidc` (POST IdP cross-site)
|
||||
- **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
|
||||
- **UI admin** — onglet « SSO / Enterprise » dans Settings : statut, config
|
||||
SAML (Entity ID, SSO/SLO URL, certificat PEM, mapping attributs JSON, signature
|
||||
des AuthnRequests) et OIDC (issuer, client id/secret, scope), provisioning
|
||||
(auto-provision, SSO only, espace par défaut), tableau groupes → rôles,
|
||||
URL des métadonnées SP copiable, bouton « Re-sync des groupes », désactivation ;
|
||||
bouton SSO sur la page de login (nom dynamique, masqué si non configuré)
|
||||
- **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`,
|
||||
`get_sso_roles()`, `sync_sso_permissions()` (les grants SSO sont des lignes
|
||||
`workspace_members` explicites ; le fallback « viewer » implicite n'en est pas un)
|
||||
- **Help** — section « Enterprise SSO » + badge `.help-badge.sso` dans `/help`
|
||||
- **`.env` (fallback bootstrap)** — `SSO_PROVIDER`, `SSO_NAME`, `SSO_ONLY`,
|
||||
`SSO_AUTO_PROVISION`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`,
|
||||
`SSO_X509_CERTIFICATE`, `SSO_SIGN_REQUESTS`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID`,
|
||||
`SSO_CLIENT_SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`,
|
||||
`SSO_DEFAULT_WORKSPACE_ID` (la config admin prime sur l'environnement ;
|
||||
section ajoutée dans `.env.example`)
|
||||
- **Dépendances** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0`
|
||||
|
||||
### Changed
|
||||
|
||||
- `/logout` délègue la déconnexion à l'IdP (SLO) quand la session vient de SAML,
|
||||
puis détruit la session locale ; sinon comportement existant
|
||||
- OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
|
||||
(operation IDs uniques — les routes multi-méthodes sont enregistrées en deux
|
||||
routes mono-méthode)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v67_sso.py` : **38 tests** — API de configuration admin
|
||||
(validation, CSRF, secret jamais renvoyé, secret vide préservé, réinitialisation,
|
||||
DELETE), métadonnées SP, login SAML complet contre un IdP mock signé (y compris
|
||||
SLO), OIDC complet (discovery/token/userinfo/JWKS mockés), cas négatifs
|
||||
(mauvais audience/issuer/destination/nonce/state, rejeu, signature altérée,
|
||||
assertion expirée), auto-provisioning, group mapping (nom → rôle workspace),
|
||||
sso_only, historique, rate limit, endpoint public `providers`, extension
|
||||
`PermissionManager`
|
||||
- `pytest -n auto` → **802 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
|
||||
---
|
||||
|
||||
## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace
|
||||
|
||||
> Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient
|
||||
> pilotable par des intégrations tierces via `/api/v2`, et les skills deviennent
|
||||
> partageables (export/import portable + galerie de presets installables).
|
||||
|
||||
### Added
|
||||
|
||||
- **API publique agent** — `app/routers/api_v2_agent.py` (15 routes, Bearer + scopes `read`/`write`,
|
||||
rate limit par token, idempotence, `api_audit_log`) :
|
||||
- `GET/POST /api/v2/agents`, `GET/PUT/DELETE /api/v2/agents/{id}`
|
||||
- `GET/POST /api/v2/agents/conversations`, `GET/DELETE .../{id}`, `GET .../{id}/actions`
|
||||
- `POST /api/v2/agents/conversations/{id}/run` — **run synchrone JSON** (le flux SSE reste interne) :
|
||||
`{status, final, error, reasoning[], actions[], events[], duration_ms}` (500 si `failed`)
|
||||
- `POST /api/v2/agents/{id}/trigger` — déclenchement externe d'un agent custom
|
||||
- propriété des conversations vérifiée par `user_id` → `404` pour un token tiers
|
||||
- **Marketplace de skills** — `app/services/skill_gallery.py` (source unique pour l'interne et le v2) :
|
||||
- `GET /api/v2/skills/{id}/export` → document portable `{format: "flowdeck-skill", version: 1, skill{…}}`
|
||||
(aucun id/workspace/auteur local) ; `POST /api/v2/skills/import` (`409` sur collision, `overwrite: true` pour écraser)
|
||||
- `GET /api/v2/skills/gallery` + `POST /api/v2/skills/gallery/{slug}/install` — **6 presets** :
|
||||
rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
|
||||
- `GET/POST/DELETE /api/v2/skills`, `GET /api/v2/skills/{id}`, `POST /api/v2/skills/{id}/apply`
|
||||
- routes jumelles session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
|
||||
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
|
||||
(le CRUD skills n'avait **aucune** suppression)
|
||||
- **UI** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage du chip
|
||||
- **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis (seul
|
||||
`agent.run.finished` l'était) via `_fire_agent_webhook()` ; les 3 sont déjà au catalogue → abonnement `agent.*`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Docs** — `docs/API_GUIDE_V6.md` : nouveau §2.4 (tables d'endpoints + règles agent) ; nom d'événement
|
||||
corrigé `agent.run.completed` → `agent.run.finished` ; `ROADMAP.md` : 5 phases agent toutes cochées + section v6.6.0
|
||||
- **OpenAPI** — `docs/openapi-v2.json` régénéré : **427 chemins** (was 402), `info.version = 6.6.0`
|
||||
- **Version** — 6.6.0 (`VERSION` + `app/main.py`)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, idempotence, run synchrone,
|
||||
ownership 404, trigger, export/import/validation, galerie + validité des outils des presets,
|
||||
routes internes, cycle de vie webhooks started→finished et started→failed)
|
||||
- `pytest -n auto` → **764 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
|
||||
## v6.5.1 (2026-09-24) — Tests webhooks_v2 complets + roadmap rattrapée
|
||||
|
||||
> Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,8 +49,10 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v6.3)
|
||||
### API & Intégrations (v6.3–v6.6)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API agent publique (v6.6)**: `/api/v2/agents/*` — agents, conversations, **run synchrone JSON**, journal d'actions + rollback, `trigger` externe
|
||||
- **Marketplace de skills (v6.6)**: export/import portable + galerie de 6 presets installables (`/api/v2/skills/*`), section « Galerie » dans la palette `/` de l'agent
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
@@ -87,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
python3 -m pytest tests/ -v # 764/764 passent (0 skip)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+270
-21
@@ -404,11 +404,11 @@ app/
|
||||
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
|
||||
|
||||
#### Plan de migration (5 phases)
|
||||
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||
1. **Phase 1 — Core Agent** ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
|
||||
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
||||
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
||||
3. **Phase 3 — Outils avancés** ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||
4. **Phase 4 — Autonomie** ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
|
||||
5. **Phase 5 — Plateforme** ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills
|
||||
|
||||
#### Limitations (v1)
|
||||
- Pas de modification concurrente (lock optimiste DB)
|
||||
@@ -849,6 +849,70 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.7.0 — SSO / SAML + OIDC entreprise (Enterprise Auth) ✅ (2026-09-24)
|
||||
|
||||
> **Objectif** : dernière feature de la roadmap v6.0.0 — authentification fédérée
|
||||
> via un IdP d'entreprise. **COMPLETED**. Design : [`docs/V6_SSO_SAML_Enterprise_Auth.md`](docs/V6_SSO_SAML_Enterprise_Auth.md).
|
||||
|
||||
- [x] **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : login SP-initié
|
||||
(`/auth/saml/login`), ACS signé (signature/audience/destination/InResponseTo validés),
|
||||
SP metadata public (`/auth/saml/metadata`), SLO (`/auth/saml/logout`) ; `allowSingleLabelDomains`
|
||||
pour les hôts homelab/LAN
|
||||
- [x] **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : `state` +
|
||||
`code_verifier` en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h
|
||||
- [x] **Provisioning** — `app/services/sso_provisioning.py` : auto-provision au 1er login
|
||||
(`auth_method=saml|oidc`, lien par email), rôle par défaut + `default_workspace_id`,
|
||||
mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode **SSO only**
|
||||
(login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay `sso_requests`,
|
||||
historique `sso_login_history`
|
||||
- [x] **Routeur** — `app/routers/sso.py` : parcours navigateur + API admin
|
||||
`GET/POST/DELETE /api/v2/sso/config` (admin + CSRF), `GET /api/v2/sso/workspaces`,
|
||||
`POST /api/v2/sso/sync` ; CSRF exclut `/auth/saml/*` + `/auth/oidc/*`
|
||||
- [x] **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
|
||||
- [x] **UI admin** — onglet « SSO / Enterprise » dans Settings (config SAML/OIDC, provisioning,
|
||||
groupes → rôles, URL métadonnées SP copiable, désactivation) ; bouton SSO sur la page de login
|
||||
- [x] **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, `get_sso_roles()`,
|
||||
`sync_sso_permissions()`
|
||||
- [x] **Help** — section « SSO (Enterprise) » + badge `.help-badge.sso`
|
||||
- [x] **Docs** — CHANGELOG, WORKLOAD, README, ARCHITECTURE, `docs/API_GUIDE_V6.md` §2.5,
|
||||
OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
|
||||
- [x] **Tests** — `tests/test_v67_sso.py` : **38 tests** (config admin + CSRF + secret préservé,
|
||||
métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper,
|
||||
group mapping, `sso_only`, SLO, historique, rate limit, extension `PermissionManager`)
|
||||
- [x] **Version** — 6.7.0 · `ruff check app tests` OK · **suite 802 verts, 0 skip**
|
||||
|
||||
---
|
||||
|
||||
## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24)
|
||||
> **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » :
|
||||
> exposer l'agent en API publique pour les intégrations tierces et rendre les skills
|
||||
> partageables (marketplace). **COMPLETED**. Voir [`docs/API_GUIDE_V6.md` §2.4](docs/API_GUIDE_V6.md).
|
||||
|
||||
- [x] **API publique agent** — `app/routers/api_v2_agent.py` (15 routes `/api/v2/agents/*`,
|
||||
Bearer + scopes `read`/`write`, rate limit par token, idempotence `Idempotency-Key`, `api_audit_log`) :
|
||||
- agents CRUD, conversations + messages, **run synchrone JSON** (le flux SSE reste réservé à l'UI),
|
||||
journal `agent_actions` + `POST /agents/actions/{id}/undo`, `POST /agents/{id}/trigger`
|
||||
- propriété des conversations vérifiée par `user_id` : un token tiers reçoit `404` (pas de fuite)
|
||||
- réponse de run : `{status, final, error, reasoning[], actions[], events[], duration_ms}`
|
||||
- [x] **Marketplace skills** — `app/services/skill_gallery.py` (source unique interne + v2) :
|
||||
- **export portable** `{format: "flowdeck-skill", version: 1, skill{name, description, prompt_template, allowed_tools}}`
|
||||
(aucun id/workspace/auteur local ne fuit) + **import** avec contrôle de collision (`409`, `overwrite: true` pour écraser)
|
||||
- **galerie de 6 presets** installables : rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
|
||||
(`GET /api/v2/skills/gallery`, `POST /api/v2/skills/gallery/{slug}/install`)
|
||||
- routes jumelles côté session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
|
||||
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
|
||||
(comble le trou CRUD : pas de suppression de skill avant)
|
||||
- **UI branchée** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage automatique du chip
|
||||
- [x] **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis pour la 1re fois
|
||||
(seul `agent.run.finished` l'était) ; les 3 sont dans le catalogue `webhook_outbound` → abonnement `agent.*`
|
||||
- [x] **Docs** — `docs/API_GUIDE_V6.md` (§2.4 + correction du nom d'événement `agent.run.completed` → `agent.run.finished`),
|
||||
OpenAPI régénéré : `docs/openapi-v2.json` → **427 chemins**, `info.version = 6.6.0`
|
||||
- [x] **Tests** — `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, run synchrone,
|
||||
ownership 404, trigger, export/import/validation, galerie + outils valides, routes internes, cycle de vie webhooks)
|
||||
- [x] **Version** — 6.6.0 · `ruff check app tests` OK · `eslint static/js` 0 problème · **suite 764 verts, 0 skip**
|
||||
|
||||
---
|
||||
|
||||
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
|
||||
> **Objectif** : passer les synced blocks en « production » (résolution +
|
||||
> propagation fiables partout) et les faire vivre dans les databases/vues —
|
||||
@@ -878,18 +942,113 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
|
||||
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **SSO/SAML** — enterprise authentication ✅ (livré v6.7.0) — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
|
||||
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
|
||||
|
||||
---
|
||||
|
||||
## v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)
|
||||
|
||||
> **Contexte (2026-09-28)** : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité
|
||||
> (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO).
|
||||
> Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : **Sites, Forms,
|
||||
> Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP**.
|
||||
> Référence : `notion.com/product/features` + `notion.com/releases` (Workers, Meeting Notes → agents, Developer bar, MCP).
|
||||
|
||||
### v6.8.0 — Sites & Forms publics ✅ (2026-09-28)
|
||||
> **Objectif** : publier (site multi-pages) et collecter (formulaires anonymes).
|
||||
> Parité Notion Sites + Forms. **Doc** : [`docs/V68_Sites_Forms.md`](docs/V68_Sites_Forms.md)
|
||||
|
||||
- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
|
||||
- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s/<slug>/sitemap.xml`, meta robots, preview sociale
|
||||
- [x] **Gating public** — mot de passe (hash salé `password_utils`, cookie signé 24h), expiry (410), `noindex`, analytics vues (`site_views` : jour + compteur, pas d'IP brute)
|
||||
- [x] **Routes** — `GET /s/<slug>` (home), `GET /s/<slug>/<page-slug>` (résolution slug + id), `GET|POST /s/<slug>/auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
|
||||
- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>` (no-auth) + `POST /f/<token>` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
|
||||
- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
|
||||
- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
|
||||
- [x] **Tests** — `tests/test_v68_sites_forms.py` (**20 tests** : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
|
||||
- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
|
||||
|
||||
### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
|
||||
> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
|
||||
> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
|
||||
|
||||
- [x] **Embeddings** — tables `semantic_embeddings` (`resource_type`, `resource_id`, `chunk_id`, `chunk_text`, `embedding BLOB`, `model hash-256`) + `search_index_state` ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, `embed_texts()` pluggable) ; job incrémental (`updated_at > indexed_at`, batch 50, scheduler 5 min dans le lifespan) + `purge_orphans()`
|
||||
- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
|
||||
- [x] **Ask AI** — `POST /api/v2/search/ask` (`{question, workspace_id}` → `{answer_markdown, citations}`) : top-8 chunks autorisés (ACL avant prompt), `LLMClient.complete()` si provider configuré sinon extractif offline avec `[[fdpage:ID]]`, cache 10 min, rate-limit 30/min, audit
|
||||
- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
|
||||
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
|
||||
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
|
||||
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
|
||||
|
||||
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
|
||||
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
|
||||
> Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe).
|
||||
> **Doc** : [`docs/V70_Automations_Workers.md`](docs/V70_Automations_Workers.md)
|
||||
|
||||
- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
|
||||
- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
|
||||
- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
|
||||
- [x] **Compat legacy** — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
|
||||
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
||||
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
|
||||
|
||||
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
|
||||
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
||||
> **Doc** : [`docs/V71_Calendar_Meetings.md`](docs/V71_Calendar_Meetings.md)
|
||||
|
||||
- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
|
||||
- [x] **Providers sans dépendance** — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
|
||||
- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
|
||||
- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
|
||||
- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
|
||||
- [x] **Tests** — `tests/test_v71_calendar_meetings.py` (**15 tests** : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + `meeting.summarized` → automation, validations audio, 404)
|
||||
- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
|
||||
### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
|
||||
> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
|
||||
> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
|
||||
|
||||
- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
|
||||
- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
|
||||
- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https://<domain>/.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
|
||||
- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
|
||||
- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
|
||||
- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
|
||||
- [x] **Tests** — `tests/test_v72_enterprise.py` (**52 tests** : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
|
||||
- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
|
||||
|
||||
### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
|
||||
> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
|
||||
|
||||
- [x] **Teamspaces** — `teamspaces` (`workspace_id`, `name`, `private`) + `teamspace_members` (rôles owner/editor/commenter/viewer) ; `pages.teamspace_id` + `collections.teamspace_id` ; `private` → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
|
||||
- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
|
||||
- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `<pre class="mermaid">` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
|
||||
- [x] **Collab polish** — `comment_reactions` (agrégation emoji + toggle), `page_follows` (toggle + followers), `guest_shares` → `GET /g/{token>` sans compte (vue enregistrée, 404 HTML si expiré/révoqué), `page_views` (compteurs journaliers, séries sans trou)
|
||||
- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
|
||||
- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
|
||||
- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
|
||||
- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
|
||||
- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
|
||||
- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
|
||||
- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
|
||||
- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
|
||||
- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
|
||||
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
|
||||
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
@@ -899,21 +1058,33 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
|
||||
|
||||
### Cycle v6 — livré, pour mémoire
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ livré
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ livré
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ livré
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ livré
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ livré
|
||||
13. ~~**v5.14.0 → Synced blocks**~~ ✅ livré · ~~**v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO**~~ ✅ livré
|
||||
|
||||
### Cycle v7 — à livrer
|
||||
| Ordre | Version | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | **v6.8.0 → Sites & Forms publics** | M | 🔴 publier + collecter |
|
||||
| 2 | **v6.9.0 → Search sémantique + Ask AI** | M | 🔴 retrouver |
|
||||
| 3 | **v7.0.0 → Automations v2 + Workers** | L | 🔴 automatiser |
|
||||
| 4 | **v7.1.0 → Calendar sync + Meeting Notes** | M | 🟠 |
|
||||
| 5 | **v7.2.0 → SCIM + 2FA + Audit UI** | M | 🟠 enterprise |
|
||||
| 6 | **v7.3.0 → Wiki/Teamspaces + Polish** | S–M | 🟢 finition |
|
||||
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
|
||||
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -932,4 +1103,82 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés) + **v6.4.0** realtime + **v6.3.0** API v2. Aussi: case `sync.py` → Bearer cochée (livrée v6.4.0) + 7 tests webhooks_v2 dé-skipés → **suite 749 verts, 0 skip**. Reste: **SSO/SAML**.*
|
||||
*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Anomalies & dette technique — audit complet 2026-09-30
|
||||
|
||||
> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
|
||||
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
|
||||
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
|
||||
|
||||
### 🔴 P0 — Critique (avant toute exposition réseau)
|
||||
|
||||
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
|
||||
|
||||
### 🟠 P1 — Hautes
|
||||
|
||||
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
|
||||
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
|
||||
- [x] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
|
||||
- [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
|
||||
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
|
||||
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
||||
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
|
||||
- [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
|
||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
||||
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
|
||||
- [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
|
||||
|
||||
### 🟡 P2 — Moyennes
|
||||
|
||||
- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
|
||||
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
|
||||
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
|
||||
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
|
||||
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
|
||||
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
|
||||
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
|
||||
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
|
||||
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
|
||||
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
|
||||
|
||||
### 🟢 P3 — Basses / hygiène
|
||||
|
||||
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
|
||||
- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
|
||||
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
|
||||
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
|
||||
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
|
||||
- [x] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
|
||||
### ✅ Vérifié non-problème (ne pas re-checker)
|
||||
|
||||
`sort`/`direction` de l'`ORDER BY` f-string **whitelisté** (`api_v2.py:696-703`) · `permissions._grant_common(table, …)` ne reçoit que des littéraux de ses 3 appelants · `admin.py` utilise bien `Depends(admin_required)` · `/api/v2/*` = `get_bearer_user` + scopes · SCIM Bearer-only (commenté `csrf.py:19-20`) · `serve_uploaded_file` et `_file_page_disk_path` ont la bonne garde de traversal · `url_fetch._is_public_host` est un vrai garde SSRF (à réutiliser) · aucun `password_hash`/valeur de token dans les réponses API (les tests le couvrent) · `.env` bien gitignoré, aucun secret en log · les 10 schedulers ont un `try` interne · 0 test sans `assert`, 0 test sur le réseau réel.
|
||||
|
||||
### ⚡ Correctifs immédiats (avant le prochain cycle — ~30 min au total)
|
||||
|
||||
1. **A2** : commit + push + tag du cycle v7 (sinon tout le reste risque de partir avec une réinit).
|
||||
2. **A1** : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` → les 13 échecs deps disparaissent, le 14ᵉ reste à isoler.
|
||||
3. **A9** : `git rm --cached flowdeck.db flowdeck_dev.db test-commit.md upload_test.txt` + `.gitignore`/`.dockerignore` + rotation de `app_secret_key`.
|
||||
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9.**
|
||||
|
||||
+15
-3
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
|
||||
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
|
||||
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
|
||||
| v6.8.0 | Sites multi-pages + Forms publics ([doc](docs/V68_Sites_Forms.md)) | ✅ | 20 |
|
||||
| v6.9.0 | Recherche hybride + Ask AI RAG ([doc](docs/V69_Search_Ask_AI.md)) | ✅ | 24 |
|
||||
| v7.0.0 | Automations multi-étapes + Workers lite ([doc](docs/V70_Automations_Workers.md)) | ✅ | 31 |
|
||||
| v7.1.0 | Calendar sync + Meeting Notes ([doc](docs/V71_Calendar_Meetings.md)) | ✅ | 15 |
|
||||
| v7.2.0 | SCIM + 2FA + Audit UI + gouvernance agents ([doc](docs/V72_Enterprise_SCIM_2FA.md)) | ✅ | 52 |
|
||||
| v7.3.0 | Teamspaces + Verified + Polish + follow-ups ([doc](docs/V73_Wiki_Teamspaces_Polish.md)) | ✅ | 72 |
|
||||
|
||||
> **Suites de régression** : `test_v72_enterprise.py` (52) + `test_v73_wiki_polish.py` (72) = **124 verts** · suite complète `-n auto` = **1016 passed**.
|
||||
> **Follow-ups v7.3 livrés** (voir § v7.3.0 du `ROADMAP.md`): sidebar par teamspace, charts `number`/multi-DB, notif `page.updated` aux followers, unfurl `gitea:`/`github:`, page Settings → Audit — + 21 casses SSO corrigées (install `python3-saml`/`authlib`).
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -61,5 +73,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 764+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
|
||||
|
||||
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
|
||||
ID token signature is verified against the issuer JWKS via authlib's JOSE
|
||||
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
|
||||
explicitly so the rules are visible and unit-testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
|
||||
DEFAULT_OIDC_MAPPING: dict[str, str] = {
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
_DISCOVERY_TTL = 3600.0
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""OIDC processing failure — ``message`` is user-facing."""
|
||||
|
||||
|
||||
def pkce_pair() -> tuple[str, str]:
|
||||
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
||||
|
||||
|
||||
async def discover(issuer_url: str) -> dict:
|
||||
"""Fetch (and cache) the issuer's OIDC discovery document."""
|
||||
issuer = issuer_url.rstrip("/")
|
||||
url = f"{issuer}/.well-known/openid-configuration"
|
||||
now = time.time()
|
||||
hit = _discovery_cache.get(issuer)
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
|
||||
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
|
||||
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
|
||||
_discovery_cache[issuer] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
doc: dict,
|
||||
*,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
code_challenge: str,
|
||||
) -> str:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": scope or "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
|
||||
return doc["authorization_endpoint"] + sep + urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(
|
||||
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
|
||||
) -> dict:
|
||||
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
auth = None
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
if r.status_code != 200:
|
||||
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
|
||||
try:
|
||||
tokens = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
|
||||
if "error" in tokens:
|
||||
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
|
||||
return tokens
|
||||
|
||||
|
||||
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Best-effort userinfo fetch (groups often only live there)."""
|
||||
endpoint = doc.get("userinfo_endpoint")
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
data = r.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as err: # userinfo is optional enrichment
|
||||
logger.debug("userinfo fetch failed: %s", err)
|
||||
return {}
|
||||
|
||||
|
||||
def validate_id_token(
|
||||
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
|
||||
) -> dict:
|
||||
"""Verify the ID token signature and claims. Returns the claims dict."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from authlib.jose import JsonWebKey
|
||||
from authlib.jose import jwt as jose_jwt
|
||||
|
||||
if isinstance(id_token, bytes):
|
||||
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
|
||||
# str — accept both instead of crashing on ``bytes.count(".")``.
|
||||
id_token = id_token.decode()
|
||||
if not id_token or id_token.count(".") != 2:
|
||||
raise OIDCError("Missing or malformed ID token")
|
||||
|
||||
try:
|
||||
keyset = JsonWebKey.import_key_set(jwks)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
|
||||
|
||||
try:
|
||||
# Pick the key matching the token header (kid) when several are offered.
|
||||
header = json.loads(_b64url_decode(id_token.split(".")[0]))
|
||||
kid = header.get("kid")
|
||||
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
|
||||
token_obj = jose_jwt.decode(id_token, key or keyset)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"ID token signature verification failed: {err}") from err
|
||||
|
||||
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
|
||||
now = int(time.time())
|
||||
|
||||
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
|
||||
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
|
||||
aud = claims.get("aud")
|
||||
aud_list = aud if isinstance(aud, list) else [aud]
|
||||
if client_id not in aud_list:
|
||||
raise OIDCError("ID token audience does not include this client")
|
||||
exp = claims.get("exp")
|
||||
if not isinstance(exp, int) or exp < now:
|
||||
raise OIDCError("ID token expired")
|
||||
iat = claims.get("iat")
|
||||
if isinstance(iat, int) and iat > now + 300:
|
||||
raise OIDCError("ID token issued in the future")
|
||||
if nonce and claims.get("nonce") != nonce:
|
||||
raise OIDCError("ID token nonce mismatch")
|
||||
if not claims.get("sub"):
|
||||
raise OIDCError("ID token has no subject")
|
||||
return claims
|
||||
|
||||
|
||||
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
|
||||
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
|
||||
mapping = mapping or DEFAULT_OIDC_MAPPING
|
||||
identity: dict = {"_raw": claims}
|
||||
for field in ("login", "email", "full_name", "avatar_url"):
|
||||
source = mapping.get(field) or field
|
||||
value = claims.get(source, "")
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else ""
|
||||
identity[field] = str(value or "").strip()
|
||||
groups = claims.get(mapping.get("groups", "groups"), [])
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
identity["groups"] = [str(g) for g in groups if g]
|
||||
if not identity["email"]:
|
||||
identity["email"] = claims.get("email", "") or ""
|
||||
if not identity["full_name"]:
|
||||
identity["full_name"] = claims.get("name", "") or identity["email"]
|
||||
return identity
|
||||
@@ -0,0 +1,279 @@
|
||||
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
|
||||
|
||||
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
|
||||
dict and builds the SP settings from the ``sso_config`` row.
|
||||
|
||||
What the toolkit validates in strict mode (all covered by tests):
|
||||
XML schema, signature of the assertion and/or the message against the IdP
|
||||
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
|
||||
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
|
||||
against the AuthnRequest id we pass to ``process_response()`` — combined
|
||||
with the single-use ``sso_requests`` store that makes replay impossible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
|
||||
#: NameID; every other value is matched against attribute Name / FriendlyName
|
||||
#: / URI local part (so ``email`` finds both ``email`` and
|
||||
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
|
||||
DEFAULT_SAML_MAPPING: dict[str, str] = {
|
||||
"login": "nameid",
|
||||
"email": "nameid",
|
||||
"full_name": "displayName",
|
||||
"avatar_url": "avatar",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class SAMLError(Exception):
|
||||
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLIdentity:
|
||||
"""What a validated assertion tells us about the user."""
|
||||
|
||||
name_id: str
|
||||
name_id_format: str = ""
|
||||
session_index: str = ""
|
||||
attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def resolve(self, source: str) -> str:
|
||||
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
|
||||
if not source or source == "nameid":
|
||||
return self.name_id or ""
|
||||
if source in self.attributes and self.attributes[source]:
|
||||
return (self.attributes[source][0] or "").strip()
|
||||
# FriendlyName match (case-insensitive)
|
||||
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
|
||||
if source.lower() in lower and lower[source.lower()]:
|
||||
return (lower[source.lower()][0] or "").strip()
|
||||
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
|
||||
# a mapping of "email" must still find ".../claims/emailaddress".
|
||||
want = source.lower().lstrip("./")
|
||||
for name, values in self.attributes.items():
|
||||
if not values:
|
||||
continue
|
||||
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
|
||||
if local == want or local.endswith(want) or want.endswith(local):
|
||||
return (values[0] or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def external_base_url(request: Request) -> str:
|
||||
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
|
||||
proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def saml_endpoints(request: Request) -> dict[str, str]:
|
||||
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
|
||||
base = external_base_url(request)
|
||||
return {
|
||||
"entity_id": f"{base}/auth/saml/metadata",
|
||||
"acs": f"{base}/auth/saml/callback",
|
||||
"slo": f"{base}/auth/saml/logout",
|
||||
"metadata": f"{base}/auth/saml/metadata",
|
||||
}
|
||||
|
||||
|
||||
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
|
||||
"""python3-saml settings dict built from a ``sso_config`` row."""
|
||||
sign_requests = bool(cfg.get("sign_requests"))
|
||||
sp: dict = {
|
||||
"entityId": endpoints["entity_id"],
|
||||
"assertionConsumerService": {
|
||||
"url": endpoints["acs"],
|
||||
"binding": BINDING_HTTP_POST,
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": endpoints["slo"],
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"NameIDFormat": NAMEID_FORMAT_EMAIL,
|
||||
}
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
sp["privateKey"] = cfg["sp_private_key"]
|
||||
sp["x509cert"] = cfg["sp_certificate"]
|
||||
|
||||
idp: dict = {
|
||||
"entityId": cfg.get("entity_id") or "",
|
||||
"singleSignOnService": {
|
||||
"url": cfg.get("sso_url") or "",
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"x509cert": cfg.get("x509_certificate") or "",
|
||||
}
|
||||
if cfg.get("slo_url"):
|
||||
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
|
||||
|
||||
return {
|
||||
"strict": True,
|
||||
"debug": False,
|
||||
"sp": sp,
|
||||
"idp": idp,
|
||||
"security": {
|
||||
"authnRequestsSigned": sign_requests,
|
||||
"logoutRequestSigned": sign_requests,
|
||||
"logoutResponseSigned": False,
|
||||
"wantMessagesSigned": False,
|
||||
"wantAssertionsSigned": True,
|
||||
"wantNameIdEncrypted": False,
|
||||
"wantAssertionsEncrypted": False,
|
||||
"wantXmlValidation": True,
|
||||
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
||||
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
|
||||
"rejectDeprecatedAlgorithm": True,
|
||||
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
|
||||
# SP through single-label hosts (http://flowdeck/, docker service
|
||||
# names). python3-saml rejects those URLs unless this is on.
|
||||
"allowSingleLabelDomains": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
|
||||
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
return {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": script_name,
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": post_data or {},
|
||||
}
|
||||
|
||||
|
||||
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
try:
|
||||
return OneLogin_Saml2_Auth(
|
||||
_request_data(request, script_name, post_data=post_data), old_settings=settings
|
||||
)
|
||||
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
|
||||
raise SAMLError(f"Invalid SAML configuration: {err}") from err
|
||||
|
||||
|
||||
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
|
||||
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
|
||||
auth = _auth(request, cfg, "/auth/saml/login")
|
||||
try:
|
||||
url = auth.login(return_to=relay_state)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
|
||||
request_id = auth.get_last_request_id() or ""
|
||||
if not request_id:
|
||||
raise SAMLError("AuthnRequest was built without an id")
|
||||
return url, request_id
|
||||
|
||||
|
||||
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
|
||||
"""Validate the IdP's SAMLResponse and extract the identity.
|
||||
|
||||
``request_id`` is the id of the AuthnRequest we issued (from the
|
||||
single-use ``sso_requests`` row): the toolkit rejects any response whose
|
||||
``InResponseTo`` does not match it.
|
||||
"""
|
||||
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
|
||||
try:
|
||||
auth.process_response(request_id=request_id or None)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML response could not be processed: {err}") from err
|
||||
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
|
||||
if not auth.is_authenticated():
|
||||
raise SAMLError("SAML response did not authenticate the user")
|
||||
|
||||
name_id = auth.get_nameid() or ""
|
||||
if not name_id:
|
||||
raise SAMLError("SAML assertion carries no NameID")
|
||||
return SAMLIdentity(
|
||||
name_id=name_id,
|
||||
name_id_format=auth.get_nameid_format() or "",
|
||||
session_index=auth.get_session_index() or "",
|
||||
attributes=auth.get_attributes() or {},
|
||||
friendly_attributes=auth.get_friendlyname_attributes() or {},
|
||||
)
|
||||
|
||||
|
||||
def metadata_xml(request: Request, cfg: dict) -> str:
|
||||
"""SP metadata XML (for the IdP configuration screen)."""
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
settings = OneLogin_Saml2_Settings(
|
||||
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
|
||||
)
|
||||
try:
|
||||
xml = settings.get_sp_metadata()
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SP metadata: {err}") from err
|
||||
if isinstance(xml, bytes):
|
||||
xml = xml.decode("utf-8")
|
||||
return xml
|
||||
|
||||
|
||||
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
|
||||
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
|
||||
auth = _auth(request, cfg, "/auth/saml/logout")
|
||||
if not cfg.get("slo_url"):
|
||||
raise SAMLError("The IdP has no Single Logout URL configured")
|
||||
try:
|
||||
return auth.logout(
|
||||
return_to=return_to,
|
||||
name_id=name_id or None,
|
||||
session_index=session_index or None,
|
||||
)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
|
||||
|
||||
|
||||
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
|
||||
"""Process a LogoutRequest / LogoutResponse received from the IdP.
|
||||
|
||||
``form`` holds the POSTed fields, ``query`` the GET parameters (the
|
||||
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
|
||||
Returns ``(redirect_url, errors)``.
|
||||
"""
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
|
||||
if not post_data:
|
||||
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
|
||||
req_data = {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": "/auth/saml/logout",
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(query),
|
||||
"post_data": post_data,
|
||||
}
|
||||
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
|
||||
try:
|
||||
url = auth.process_slo(keep_local_session=True)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML logout could not be processed: {err}") from err
|
||||
return url, auth.get_errors()
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
@@ -31,7 +31,7 @@ class SessionManager:
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -94,7 +94,7 @@ class SessionManager:
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
|
||||
+23
-4
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||
oauth_redirect_uri: str = ""
|
||||
@@ -82,6 +79,26 @@ class Settings(BaseSettings):
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin
|
||||
# saves a configuration in Settings → Admin → SSO / Enterprise, the
|
||||
# `sso_config` table wins (see app/services/sso_provisioning.py).
|
||||
sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled)
|
||||
sso_name: str = "Company SSO" # button label on the login page
|
||||
sso_entity_id: str = "" # SAML: IdP entity id
|
||||
sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect)
|
||||
sso_slo_url: str = "" # SAML: IdP Single Logout URL
|
||||
sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM)
|
||||
sso_issuer_url: str = "" # OIDC: issuer identifier
|
||||
sso_client_id: str = "" # OIDC: client id
|
||||
sso_client_secret: str = "" # OIDC: client secret (env only)
|
||||
sso_scope: str = "openid profile email"
|
||||
sso_attribute_mapping: str = "" # JSON, defaults per provider
|
||||
sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}]
|
||||
sso_auto_provision: bool = True
|
||||
sso_only: bool = False # refuse local login when true
|
||||
sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest
|
||||
sso_default_workspace_id: int = 0
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
@@ -109,7 +126,9 @@ class Settings(BaseSettings):
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z]:', p):
|
||||
return Path(p)
|
||||
return Path("/" + p)
|
||||
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
|
||||
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
|
||||
return Path("/" + p.lstrip("/"))
|
||||
return Path("/data/flowdeck.db")
|
||||
|
||||
|
||||
|
||||
@@ -837,6 +837,11 @@ def get_conn():
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
|
||||
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
|
||||
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
|
||||
# l'event loop) reste à migrer module par module.
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
|
||||
+108
-18
@@ -39,18 +39,29 @@ from app.routers import (
|
||||
workspace,
|
||||
)
|
||||
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(
|
||||
@@ -60,55 +71,107 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _spawn(name: str, factory):
|
||||
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
|
||||
|
||||
Loggue l'exception puis recrée la coroutine 10 s plus tard.
|
||||
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
|
||||
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
|
||||
le bruit devient un problème.
|
||||
"""
|
||||
|
||||
async def _guard():
|
||||
while True:
|
||||
try:
|
||||
await factory()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
else:
|
||||
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return asyncio.create_task(_guard())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
|
||||
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
|
||||
if settings.app_secret_key == "change-me-to-random":
|
||||
raise RuntimeError(
|
||||
"APP_SECRET_KEY non défini — générer une valeur : "
|
||||
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone():
|
||||
admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password(admin_pw),),
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
"Premier démarrage : compte admin créé, mot de passe = %s "
|
||||
"(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)",
|
||||
admin_pw,
|
||||
)
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
automation_task = _spawn("automation_scheduler", automation_scheduler)
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(backup_scheduler())
|
||||
backup_task = _spawn("backup_scheduler", backup_scheduler)
|
||||
|
||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||
from app.services.projects import project_sync_scheduler
|
||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
||||
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
|
||||
|
||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
@@ -122,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.5.1",
|
||||
version="7.3.9",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -135,6 +198,7 @@ app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sso_router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
@@ -169,6 +233,18 @@ app.include_router(permissions_router)
|
||||
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")
|
||||
|
||||
@@ -258,17 +334,31 @@ async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
is_api_v2 = request.url.path.startswith("/api/v2")
|
||||
# Programmatic API prefixes that must always answer JSON errors instead of
|
||||
# being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch).
|
||||
JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn")
|
||||
is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES)
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
if is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
if is_json_api and request.url.path.startswith("/scim/v2"):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": detail if isinstance(detail, str) else "Not found",
|
||||
"status": "404"},
|
||||
status_code=404,
|
||||
headers={"Content-Type": "application/scim+json"},
|
||||
)
|
||||
if "/api" in request.url.path or is_json_api:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
if is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
+17
-1
@@ -16,7 +16,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
||||
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
||||
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||
# library, gitea_workspace, workspace, workspaces, welcome).
|
||||
# Ne restent que du machine-to-machine / hors session :
|
||||
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
|
||||
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
|
||||
# - publics : /s/ (sites), /f/ (forms)
|
||||
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||
EXCLUDED_PATHS = {
|
||||
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
|
||||
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
||||
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
||||
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||
# API workspace + collections (les endpoints mutants du legacy).
|
||||
"/scim/v2/", "/workspace/", "/db/",
|
||||
)
|
||||
|
||||
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||
# visiteurs d'un site publié qui partagent une IP.
|
||||
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
self._last_prune = 0.0
|
||||
self._max_keys = 5000
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||
method = request.method.upper()
|
||||
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||
method not in ("GET", "HEAD", "OPTIONS")
|
||||
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||
)
|
||||
if not limited:
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
limit = self.max_requests or settings.rate_limit_requests
|
||||
ip = self._client_key(request)
|
||||
now = time.time()
|
||||
|
||||
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||
self._prune(now)
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
if count >= limit:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
|
||||
def _client_key(self, request: Request) -> str:
|
||||
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||
|
||||
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||
internaute, donc le XFF du proxy fait foi.
|
||||
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||
exposée directement), prendre la dernière adresse non privée de la
|
||||
chaîne plutôt que la première.
|
||||
"""
|
||||
host = request.client.host if request.client else "unknown"
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
try:
|
||||
direct = ipaddress.ip_address(host)
|
||||
if direct.is_private or direct.is_loopback:
|
||||
return fwd.split(",")[0].strip() or host
|
||||
except ValueError:
|
||||
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||
return host
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||
for k in expired:
|
||||
del self._store[k]
|
||||
self._last_prune = now
|
||||
|
||||
+538
-1
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_migration_v630_api_v2")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -983,3 +983,540 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(24, "v6.8.0: Sites & public Forms")
|
||||
def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
||||
"""v6.8.0 — Notion Sites + Forms publics (voir docs/V68_Sites_Forms.md).
|
||||
|
||||
``sites`` — mini-site multi-pages (slug, root_page, thème,
|
||||
domaine custom, password hash, expiry, noindex).
|
||||
``site_pages`` — arbre public ordonné (site_id, page_id, position).
|
||||
``site_views`` — compteur de vues jour/site (upsert, pas d'IP brute).
|
||||
``form_responses`` — log des soumissions anonymes (ip_hash jour, pas d'IP).
|
||||
``collections.form_config_json`` — config du formulaire public par DB.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
theme TEXT NOT NULL DEFAULT 'dark',
|
||||
custom_domain TEXT UNIQUE,
|
||||
password_hash TEXT DEFAULT '',
|
||||
expires_at TIMESTAMP,
|
||||
noindex INTEGER NOT NULL DEFAULT 0,
|
||||
analytics_id TEXT DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS site_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(site_id, page_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_site_pages_site ON site_pages(site_id, position)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS site_views (
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (site_id, day)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS form_responses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
row_id INTEGER REFERENCES collection_pages(id) ON DELETE SET NULL,
|
||||
ip_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "form_config_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
|
||||
|
||||
@register(25, "v6.9.0: semantic search + Ask AI")
|
||||
def _migration_semantic_search(conn: sqlite3.Connection) -> None:
|
||||
"""v6.9.0 — hybrid lexical+vector search and RAG Ask AI (docs/V69_* md).
|
||||
|
||||
``semantic_embeddings`` — hashed-TF chunk vectors (no external dep):
|
||||
keyed by (resource_type, resource_id, chunk_id) so both ``page``
|
||||
and ``collection`` resources are indexed. (Design doc names a
|
||||
``page_embeddings`` table; the generic key covers collections too.)
|
||||
``semantic_index_state`` — last indexed timestamp per resource for the
|
||||
incremental background job.
|
||||
``pages.search_excluded`` — opt-out flag respected by indexer + search.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS semantic_embeddings (
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id INTEGER NOT NULL,
|
||||
chunk_id INTEGER NOT NULL,
|
||||
chunk_text TEXT NOT NULL DEFAULT '',
|
||||
embedding BLOB NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT 'hash-256',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (resource_type, resource_id, chunk_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sem_emb_res "
|
||||
"ON semantic_embeddings(resource_type, resource_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS semantic_index_state (
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id INTEGER NOT NULL,
|
||||
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (resource_type, resource_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "search_excluded" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
|
||||
@register(26, "v7.0.0: automations v2 (steps) + workers")
|
||||
def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
|
||||
"""v7.0.0 — multi-step automations + sandboxed workers (docs/V70_* md).
|
||||
|
||||
``automation_steps`` — ordered trigger/condition/delay/action chain per
|
||||
automation. Legacy single trigger+actions columns keep working
|
||||
(engine falls back when an automation has no steps).
|
||||
``automations.trigger_mode`` — ``any`` (default) or ``all`` (every
|
||||
trigger event must arrive within a 5-minute window).
|
||||
``workers`` / ``worker_runs`` — custom Python snippets (cron/manual),
|
||||
shareable across the team, with execution logs + daily budget.
|
||||
``collection_properties.button_automation_id`` — native DB button cells.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automation_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asteps_auto "
|
||||
"ON automation_steps(automation_id, position)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code_py TEXT NOT NULL DEFAULT '',
|
||||
schedule_cron TEXT DEFAULT '',
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
daily_budget_s INTEGER NOT NULL DEFAULT 60,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS worker_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
logs TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||
"ON worker_runs(worker_id, created_at)"
|
||||
)
|
||||
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "button_automation_id" not in prop_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
||||
"INTEGER REFERENCES automations(id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(27, "v7.1.0: calendar sync + meeting transcripts")
|
||||
def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
|
||||
"""v7.1.0 — external calendar sync + AI meeting notes (docs/V71_* md).
|
||||
|
||||
``calendar_links`` — per-user link between a collection and an external
|
||||
calendar (google REST / generic caldav), tokens Fernet-encrypted.
|
||||
``meeting_transcripts`` — uploaded audio + transcript + AI summary per page.
|
||||
``collection_pages.external_event_id`` — remote event id for push/pull
|
||||
matching and conflict detection.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS calendar_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
tokens_enc TEXT NOT NULL DEFAULT '',
|
||||
calendar_id TEXT NOT NULL DEFAULT 'primary',
|
||||
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
|
||||
date_property TEXT DEFAULT '',
|
||||
sync_token TEXT DEFAULT '',
|
||||
last_sync TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider, calendar_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_transcripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
audio_path TEXT NOT NULL DEFAULT '',
|
||||
transcript TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT 'fr',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||
"ON meeting_transcripts(page_id)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
if "external_event_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cp_external "
|
||||
"ON collection_pages(collection_id, external_event_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(28, "v7.2.0: SCIM + 2FA + audit UI + agent governance")
|
||||
def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
|
||||
"""v7.2.0 — enterprise admin (docs/V72_* md).
|
||||
|
||||
``scim_tokens`` — Bearer tokens for SCIM provisioning (admin-managed).
|
||||
``domain_claims`` — DNS/well-known verified domains + SSO enforcement.
|
||||
``webauthn_credentials`` — passkeys (credential_id, COSE public key).
|
||||
``agent_policies`` — per-workspace tool scope + approval gate.
|
||||
``agent_approvals`` — approval queue for gated write actions.
|
||||
``users.totp_secret_enc`` / ``totp_backup_hashes`` — TOTP 2FA.
|
||||
(``users.is_active`` already exists — used by SCIM suspend.)
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scim_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS domain_claims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
txt_token TEXT NOT NULL DEFAULT '',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
auto_join_role TEXT NOT NULL DEFAULT 'viewer',
|
||||
enforce_sso INTEGER NOT NULL DEFAULT 0,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
allowed_tools_json TEXT,
|
||||
max_steps INTEGER NOT NULL DEFAULT 12,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL DEFAULT 0,
|
||||
tool TEXT NOT NULL DEFAULT '',
|
||||
args_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requester_id INTEGER REFERENCES users(id),
|
||||
approver_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||
"ON agent_approvals(status, created_at)"
|
||||
)
|
||||
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "totp_secret_enc" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||
if "totp_backup_hashes" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_backup_hashes TEXT DEFAULT '[]'")
|
||||
|
||||
|
||||
@register(29, "v7.3.0: teamspaces + verified pages + collab polish")
|
||||
def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
|
||||
"""v7.3.0 — teamspaces, verified pages, collab polish (docs/V73_*.md).
|
||||
|
||||
``teamspaces`` / ``teamspace_members`` — namespaces for pages + databases;
|
||||
``private=1`` hides a teamspace from non-members (404, like restricted
|
||||
collections). ``page_verifications`` — ✅ badge with expiry.
|
||||
``comment_reactions`` / ``page_follows`` — collab polish.
|
||||
``guest_shares`` — account-less page access via ``/g/<token>``.
|
||||
``page_views`` — daily counters, same pattern as ``site_views``.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS teamspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
private INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS teamspace_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(teamspace_id, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_teamspace_members_user "
|
||||
"ON teamspace_members(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_verifications (
|
||||
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
|
||||
verified_by INTEGER REFERENCES users(id),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_verifications_expiry "
|
||||
"ON page_verifications(expires_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS comment_reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(comment_id, user_id, emoji)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_follows (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (page_id, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS guest_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
expires_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (page_id, day)
|
||||
)
|
||||
"""
|
||||
)
|
||||
for table in ("pages", "collections"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "teamspace_id" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||
|
||||
|
||||
@register(23, "v6.7.0: SSO/SAML enterprise auth")
|
||||
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
|
||||
|
||||
``sso_config`` — single active SSO provider (SAML or OIDC), managed
|
||||
from Settings → Admin → SSO / Enterprise. Secrets
|
||||
(``client_secret``, SP private key) are encrypted at
|
||||
rest by ``app.services.sso_provisioning``.
|
||||
``sso_login_history`` — audit trail of every SSO login attempt (successes
|
||||
AND rejections — signature failure, replay, no
|
||||
local account…).
|
||||
``sso_requests`` — single-use anti-replay store: AuthnRequest ids and
|
||||
OIDC states, CSRF relay tokens, PKCE verifiers and
|
||||
the post-login redirect target. One row is consumed
|
||||
by exactly one callback.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'saml',
|
||||
name TEXT NOT NULL DEFAULT 'Company SSO',
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
sso_url TEXT NOT NULL DEFAULT '',
|
||||
slo_url TEXT DEFAULT '',
|
||||
x509_certificate TEXT NOT NULL DEFAULT '',
|
||||
issuer_url TEXT DEFAULT '',
|
||||
client_id TEXT DEFAULT '',
|
||||
client_secret TEXT DEFAULT '',
|
||||
scope TEXT DEFAULT 'openid profile email',
|
||||
attribute_mapping TEXT NOT NULL DEFAULT '{}',
|
||||
groups_mapping TEXT NOT NULL DEFAULT '[]',
|
||||
auto_provision INTEGER NOT NULL DEFAULT 1,
|
||||
sso_only INTEGER NOT NULL DEFAULT 0,
|
||||
sign_requests INTEGER NOT NULL DEFAULT 0,
|
||||
default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
sp_private_key TEXT DEFAULT '',
|
||||
sp_certificate TEXT DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL DEFAULT 'SSO',
|
||||
sso_identifier TEXT,
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_history_user "
|
||||
"ON sso_login_history(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
relay_state TEXT NOT NULL DEFAULT '',
|
||||
code_verifier TEXT NOT NULL DEFAULT '',
|
||||
next_path TEXT NOT NULL DEFAULT '/workspaces',
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
+120
-24
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -14,6 +15,7 @@ from fastapi.responses import StreamingResponse
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -50,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
triggers = conn.execute(
|
||||
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
||||
).fetchall()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
for trig in triggers:
|
||||
last = trig["last_fired_at"]
|
||||
if last:
|
||||
@@ -91,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int | None:
|
||||
async def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = await get_current_user(request)
|
||||
if user and user.get("id"):
|
||||
return user["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else None
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
@@ -112,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
"""Require an admin session. Falls back to the single admin row, matching
|
||||
the agent router's unauthenticated convention (single-user deployments)."""
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = await get_current_user(request)
|
||||
if user:
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return dict(row)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
|
||||
|
||||
def _default_agent(conn, user_id: int) -> dict:
|
||||
@@ -524,6 +522,81 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
|
||||
|
||||
|
||||
# ── Skill marketplace (v6.6.0, Agent phase 5) ──
|
||||
# Galerie de presets + export/import portable — même implémentation que
|
||||
# l'API publique (/api/v2/skills/*), via app.services.skill_gallery.
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
@@ -921,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
return {"ok": False, "provider": provider, "error": str(exc)}
|
||||
|
||||
|
||||
def _check_api_base(value: str) -> str:
|
||||
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
|
||||
|
||||
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
|
||||
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
|
||||
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
|
||||
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
|
||||
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
|
||||
settings `llm_allow_private=false`.
|
||||
"""
|
||||
url = (value or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
|
||||
return url
|
||||
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
@@ -932,7 +1028,7 @@ async def update_provider_config(request: Request):
|
||||
provider=provider or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
clear_keys=(provider == "offline"),
|
||||
)
|
||||
llm = LLMClient()
|
||||
@@ -961,7 +1057,7 @@ async def test_provider_config(request: Request):
|
||||
llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
)
|
||||
try:
|
||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||
|
||||
+28
-24
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
|
||||
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
|
||||
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
|
||||
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
|
||||
|
||||
|
||||
async def _require_session_or_bearer(request: Request) -> None:
|
||||
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
|
||||
if request.url.path in _API_PUBLIC_PATHS:
|
||||
return
|
||||
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
from app.routers.public_api import verify_token
|
||||
verify_token(auth)
|
||||
return
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
|
||||
|
||||
# ── Simple rate limiter (in-memory, per-IP) ──
|
||||
_rate_limit_store: dict[str, tuple[float, int]] = {}
|
||||
@@ -26,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.utcnow().timestamp()
|
||||
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
|
||||
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||
if now - window_start > 60:
|
||||
_rate_limit_store[ip] = (now, 1)
|
||||
@@ -47,12 +67,12 @@ async def health(request: Request):
|
||||
conn.execute("SELECT 1")
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
try:
|
||||
await gitea.get_user_repos(page=1, limit=1)
|
||||
gitea_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
@@ -79,22 +99,6 @@ async def stats():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
|
||||
"""List Gitea projects (JSON)."""
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception:
|
||||
repos = []
|
||||
return {"projects": repos}
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_card(
|
||||
request: Request,
|
||||
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
|
||||
+37
-42
@@ -165,7 +165,7 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("get_me")
|
||||
# never expose secrets
|
||||
return d
|
||||
|
||||
@@ -261,7 +261,7 @@ async def create_workspace(request: Request, authorization: str | None = Header(
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_workspace")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "workspace.create", "workspace", wid, name, request)
|
||||
@@ -497,17 +497,13 @@ async def create_collection_v2(request: Request, authorization: str | None = Hea
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# materialize properties if schema provided
|
||||
try:
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
except Exception:
|
||||
pass
|
||||
# materialize properties if schema provided — A25 : PAS de try ici,
|
||||
# une exception doit interrompre la transaction (sinon la collection est
|
||||
# commitée sans son schéma et l'erreur disparaît).
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
# default view
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
audit_log(user, "collection.create", "collection", cid, name, request)
|
||||
@@ -593,20 +589,20 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_linked_db")
|
||||
# copy views + properties (light)
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for p in rows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_linked_db")
|
||||
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for v in vrows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_linked_db")
|
||||
conn.commit()
|
||||
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
@@ -766,7 +762,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
|
||||
pass
|
||||
# light validation: we rely on existing validators
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_collection_page_v2")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# apply auto props
|
||||
try:
|
||||
@@ -774,7 +770,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, prop_vals, user, is_create=True)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_collection_page_v2")
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
@@ -783,7 +779,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
|
||||
try:
|
||||
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_collection_page_v2")
|
||||
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
@@ -857,14 +853,14 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, stored, user, is_create=False)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("patch_page_v2")
|
||||
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
try:
|
||||
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("patch_page_v2")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
@@ -883,7 +879,7 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
|
||||
try:
|
||||
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("delete_page_v2")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
@@ -901,7 +897,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
try:
|
||||
await _fire_event("page.restored", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("restore_page_v2")
|
||||
return {"id": page_id, "status": "restored"}
|
||||
raise HTTPException(404, "Page not found or not deleted")
|
||||
|
||||
@@ -1109,7 +1105,7 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_relation_v2")
|
||||
conn.commit()
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
@@ -1184,7 +1180,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
|
||||
try:
|
||||
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_view_v2")
|
||||
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
||||
|
||||
@router.patch("/views/{view_id}")
|
||||
@@ -1208,7 +1204,7 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
|
||||
try:
|
||||
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("patch_view_v2")
|
||||
# also flat keys
|
||||
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
|
||||
if k in body:
|
||||
@@ -1350,7 +1346,7 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_comment_v2")
|
||||
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
@@ -1378,7 +1374,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("patch_comment_v2")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
@@ -1419,13 +1415,13 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
|
||||
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
|
||||
created += 1
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_mention_v2")
|
||||
conn.commit()
|
||||
if created:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_mention_v2")
|
||||
return {"mentions": created, "status": "created"}
|
||||
|
||||
# ── Notifications ─────────────────────────────────────────────────────────
|
||||
@@ -1522,7 +1518,7 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
|
||||
try:
|
||||
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite_v2")
|
||||
return {"page_id": pid, "status": "added"}
|
||||
|
||||
@router.delete("/favorites/{page_id}")
|
||||
@@ -1537,7 +1533,7 @@ async def remove_favorite_v2(page_id: int, request: Request, authorization: str
|
||||
try:
|
||||
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("remove_favorite_v2")
|
||||
return {"page_id": page_id, "status": "removed"}
|
||||
|
||||
@router.get("/tags")
|
||||
@@ -1687,7 +1683,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_share_v2")
|
||||
return {"id": nid, "page_id": page_id, "status": "shared"}
|
||||
|
||||
@router.patch("/shares/{share_id}")
|
||||
@@ -1748,7 +1744,7 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("publish_page_v2")
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
@@ -1763,7 +1759,7 @@ async def unpublish_page_v2(page_id: int, request: Request, authorization: str |
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("unpublish_page_v2")
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────
|
||||
@@ -1844,7 +1840,7 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
|
||||
try:
|
||||
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_sprint_v2")
|
||||
return {"id": sid, "name": name, "status": "created"}
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
@@ -1871,7 +1867,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
|
||||
try:
|
||||
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("patch_sprint_v2")
|
||||
return {"id": sprint_id, "status": "updated"}
|
||||
|
||||
@router.delete("/sprints/{sprint_id}")
|
||||
@@ -1937,7 +1933,7 @@ async def burndown_v2(sprint_id: int, request: Request, authorization: str | Non
|
||||
completed += 1
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("burndown_v2")
|
||||
remaining = total - completed
|
||||
# ideal linear
|
||||
ideal = [round(total * (1 - i / 10)) for i in range(11)]
|
||||
@@ -2047,11 +2043,10 @@ async def apply_db_template_v2(template_id: int, request: Request, authorization
|
||||
schema = json.loads(tpl["schema_json"] or "[]")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
|
||||
cid = cur.lastrowid
|
||||
try:
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
except Exception:
|
||||
pass
|
||||
# A25 : pas de try — un échec de matérialisation doit interrompre la
|
||||
# transaction plutôt que de commiter une collection sans schéma.
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""FlowDeck — Public API v2 : Agent & Skill marketplace (v6.6.0, phase 5).
|
||||
|
||||
Thin Bearer+scopes wrappers over the existing agent logic (AgentEngine,
|
||||
`agent_skills`, the gallery service) so third-party integrations can drive
|
||||
FlowDeck Agent without a browser session:
|
||||
|
||||
* ``/api/v2/agents`` — agents CRUD, conversations, synchronous runs (JSON,
|
||||
the SSE stream stays an internal/UI concern), audit journal & rollback.
|
||||
* ``/api/v2/skills`` — the skill marketplace: CRUD, portable export/import and
|
||||
the built-in gallery of installable presets.
|
||||
|
||||
Rules honoured (see docs/API_GUIDE_V6.md): one code path (the engine and the
|
||||
gallery service are reused, never re-implemented), JSON only, no secrets or
|
||||
internal columns, rate limit + audit + idempotency on every mutation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.agent import _default_agent
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
)
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2-agent"])
|
||||
|
||||
|
||||
# ── Shared guards ──────────────────────────────────────────────────────────
|
||||
|
||||
def _guard(request: Request, authorization: str | None, *, write: bool = False) -> dict:
|
||||
"""Bearer auth + per-token rate limit (+ write scope when required)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_v2_rate_limit(user.get("_token_hash"), ip):
|
||||
raise HTTPException(429, "Rate limit exceeded: 300 req/min per token")
|
||||
if write and not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
|
||||
|
||||
async def _json_body(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _workspace_of(request: Request, body: dict | None = None) -> int | None:
|
||||
"""Workspace resolution mirrors the internal agent router: explicit param
|
||||
wins, then the token's own workspace, else NULL (shared/global scope)."""
|
||||
body = body or {}
|
||||
raw = body.get("workspace_id") or request.query_params.get("workspace_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _owned_conversation(conn, conversation_id: int, user_id: int):
|
||||
"""Conversation visible to this token's user (ownership is enforced here,
|
||||
unlike the session router where the browser is already authenticated)."""
|
||||
return conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
(conversation_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) -> AgentEngine:
|
||||
engine = AgentEngine(user_id, workspace_id=workspace_id)
|
||||
if provider:
|
||||
user_key = get_user_llm_key(user_id, provider)
|
||||
if user_key and user_key.get("api_key"):
|
||||
engine.llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=user_key["api_key"],
|
||||
api_base=user_key.get("api_base") or None,
|
||||
)
|
||||
else:
|
||||
engine.llm = LLMClient(provider=provider)
|
||||
return engine
|
||||
|
||||
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user["id"])
|
||||
clause = "WHERE workspace_id IS ? OR workspace_id=?"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM agents {clause}", (ws, ws)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM agents {clause} ORDER BY agent_type, name LIMIT ? OFFSET ?",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"agents": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents")
|
||||
async def create_agent_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agents (workspace_id, name, icon, agent_type, description,
|
||||
system_instructions, model, scope_json, trigger_json, approval_mode, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"),
|
||||
body.get("description", ""), body.get("system_instructions", ""),
|
||||
body.get("model", "gpt-4o"),
|
||||
json.dumps(body.get("scope", {})), json.dumps(body.get("trigger", {})),
|
||||
body.get("approval_mode", "auto"), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
agent_id = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(409, f"Cannot create agent: {exc}") from exc
|
||||
audit_log(user, "agent.create", "agent", agent_id, name, request)
|
||||
data = {"id": agent_id, "name": name, "status": "created", "agent": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
sets, params = [], []
|
||||
for col in ("name", "icon", "description", "system_instructions", "model",
|
||||
"approval_mode", "is_active"):
|
||||
if col in body:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(body[col])
|
||||
if "scope" in body:
|
||||
sets.append("scope_json=?")
|
||||
params.append(json.dumps(body["scope"]))
|
||||
if "trigger" in body:
|
||||
sets.append("trigger_json=?")
|
||||
params.append(json.dumps(body["trigger"]))
|
||||
if sets:
|
||||
params.append(agent_id)
|
||||
conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "agent.update", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
raise HTTPException(404, "Agent not found")
|
||||
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.delete", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_conversations WHERE user_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_conversations WHERE user_id=?
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?""",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"conversations": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents/conversations")
|
||||
async def create_conversation_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
agent_id = body.get("agent_id")
|
||||
with get_conn() as conn:
|
||||
if agent_id is not None:
|
||||
agent = conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
agent_id = agent["id"]
|
||||
else:
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(agent_id, user["id"], body.get("title") or "New conversation",
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider") or "", body.get("model") or ""),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conv_id,)).fetchone()
|
||||
audit_log(user, "agent.conversation.create", "agent_conversation", conv_id, "", request)
|
||||
data = {"id": conv_id, "status": "created", "conversation": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
messages = conn.execute(
|
||||
"SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"conversation": row_to_dict(conv), "messages": [row_to_dict(m) for m in messages]}
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.conversation.delete", "agent_conversation", conversation_id, "", request)
|
||||
return {"id": conversation_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
async def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"actions": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
async def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT a.id FROM agent_actions a
|
||||
JOIN agent_conversations c ON c.id = a.conversation_id
|
||||
WHERE a.id=? AND c.user_id=?""",
|
||||
(action_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Action not found")
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(500, f"Rollback failed: {exc}") from exc
|
||||
audit_log(user, "agent.action.undo", "agent_action", action_id, "", request)
|
||||
return {"id": action_id, "status": "reverted"}
|
||||
|
||||
|
||||
# ── Runs (JSON — the SSE stream stays internal) ────────────────────────────
|
||||
|
||||
def _collect_run_events(events: list[dict]) -> dict:
|
||||
"""Aggregate an engine event stream into a JSON run result.
|
||||
|
||||
Engine events are flat (``{"type": "final", "content": ...}``), the same
|
||||
shape the SSE panel consumes.
|
||||
"""
|
||||
final = None
|
||||
reasoning = []
|
||||
actions = []
|
||||
error = None
|
||||
for ev in events:
|
||||
etype = ev.get("type")
|
||||
if etype == "final":
|
||||
final = ev.get("content") or final
|
||||
elif etype == "reasoning":
|
||||
reasoning.append(ev.get("content") or "")
|
||||
elif etype == "action":
|
||||
actions.append({k: v for k, v in ev.items() if k != "type"})
|
||||
elif etype == "error":
|
||||
error = ev.get("message") or "run failed"
|
||||
return {
|
||||
"status": "failed" if error else "completed",
|
||||
"final": final,
|
||||
"error": error,
|
||||
"reasoning": reasoning,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agents/conversations/{conversation_id}/run")
|
||||
async def run_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronous agent run: buffers the engine stream and returns JSON.
|
||||
|
||||
Third parties get one HTTP round-trip instead of an SSE subscription; the
|
||||
same AgentEngine, permissions, journal and webhooks are used as the UI.
|
||||
"""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
objective = (body.get("message") or body.get("objective") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(400, "message is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
eff_provider = body.get("provider") or conv["provider"] or None
|
||||
eff_model = body.get("model") or conv["model"] or None
|
||||
if body.get("provider") is not None or body.get("model") is not None:
|
||||
conn.execute(
|
||||
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body.get("provider", conv["provider"] or ""),
|
||||
body.get("model", conv["model"] or ""), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
conv_context = {}
|
||||
try:
|
||||
conv_context = json.loads(conv["context_json"] or "{}") or {}
|
||||
except (TypeError, ValueError):
|
||||
conv_context = {}
|
||||
|
||||
ws = _workspace_of(request, body)
|
||||
if ws is None:
|
||||
ws = conv_context.get("workspace_id")
|
||||
|
||||
engine = _engine_for(user["id"], ws, eff_provider)
|
||||
started = time.time()
|
||||
events = [
|
||||
ev async for ev in engine.run(
|
||||
conversation_id, objective,
|
||||
model=eff_model,
|
||||
mentions=body.get("mentions"),
|
||||
files=body.get("files"),
|
||||
skill_id=body.get("skill_id"),
|
||||
skill_ids=body.get("skill_ids"),
|
||||
extra_context=body.get("context"),
|
||||
)
|
||||
]
|
||||
result = _collect_run_events(events)
|
||||
with get_conn() as conn:
|
||||
actions = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
payload = {
|
||||
"conversation_id": conversation_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": [row_to_dict(a) for a in actions],
|
||||
"events": events,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.run", "agent_conversation", conversation_id, objective[:200], request)
|
||||
status_code = 200 if result["status"] == "completed" else 500
|
||||
data = payload
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, status_code)
|
||||
return JSONResponse(content=data, status_code=status_code)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/trigger")
|
||||
async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Fire a custom agent from an external integration (JSON, synchronous)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], f"Run: {agent['name']}", json.dumps({"workspace_id": ws})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »."
|
||||
if body.get("message"):
|
||||
objective = f"{objective}\n\n{body['message']}"
|
||||
engine = _engine_for(user["id"], ws, agent["model"] or None)
|
||||
started = time.time()
|
||||
events = [ev async for ev in engine.run(conv_id, objective, model=agent["model"])]
|
||||
result = _collect_run_events(events)
|
||||
payload = {
|
||||
"conversation_id": conv_id,
|
||||
"agent_id": agent_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": result["actions"],
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.trigger", "agent", agent_id, objective[:200], request)
|
||||
return JSONResponse(content=payload, status_code=200 if result["status"] == "completed" else 500)
|
||||
|
||||
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?",
|
||||
(ws, ws),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?
|
||||
ORDER BY name LIMIT ? OFFSET ?""",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"skills": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(
|
||||
{k: body[k] for k in ("name", "description", "prompt_template", "allowed_tools")
|
||||
if k in body} | {"format": skill_gallery.EXPORT_FORMAT}
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.create", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "created" if created else "updated", "skill": row_to_dict(row) if row else {}}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/v2/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill_v2(slug: str, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(404, f"Unknown gallery skill: {slug}")
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.gallery.install", "skill", row.get("id"), slug, request)
|
||||
data = {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row_to_dict(row)}
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Import a portable skill document (from another FlowDeck instance)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.import", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "skill.delete", "skill", skill_id, row["name"] or "", request)
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Open a conversation pre-loaded with the skill (ready to run)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], skill["name"],
|
||||
json.dumps({"workspace_id": ws if ws is not None else skill["workspace_id"],
|
||||
"skill_id": skill_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "skill.apply", "skill", skill_id, skill["name"], request)
|
||||
return JSONResponse(content={"conversation_id": conv_id, "skill": skill["name"],
|
||||
"status": "ready"}, status_code=201)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""FlowDeck — unified audit log API (v7.2.0).
|
||||
|
||||
Merges ``api_audit_log`` + ``permission_audit_log`` + ``sso_login_history``
|
||||
with actor/resource/date filters and CSV export (10k rows max, 365-day
|
||||
retention note). Admin only. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import (
|
||||
has_scope,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
|
||||
def _admin_user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
||||
(sess.get("id"),)).fetchone()
|
||||
if row and row["is_admin"]:
|
||||
return sess
|
||||
raise HTTPException(403, "Admin required")
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if user and user.get("is_admin") and has_scope(
|
||||
user.get("_token_scopes") or "read", "admin"):
|
||||
return user
|
||||
raise HTTPException(401, "Admin authentication required")
|
||||
|
||||
|
||||
def _query(source: str, actor: str, action: str, limit: int, offset: int):
|
||||
"""One source query → (rows, columns). All normalized to a common shape."""
|
||||
with get_conn() as conn:
|
||||
if source in ("api", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, user_id AS actor, action,
|
||||
resource_type || ':' || resource_id AS resource,
|
||||
ip_address AS ip, detail, 'api' AS source
|
||||
FROM api_audit_log
|
||||
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
||||
AND (?='' OR action LIKE ?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
||||
).fetchall()
|
||||
if source == "api":
|
||||
return rows
|
||||
api = [dict(r) for r in rows]
|
||||
else:
|
||||
api = []
|
||||
if source in ("permissions", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, performed_by AS actor, action,
|
||||
resource_type || ':' || resource_id AS resource,
|
||||
ip_address AS ip,
|
||||
('target=' || COALESCE(target_user_id, target_group_id, '')
|
||||
|| ' ' || COALESCE(old_role,'') || '→' || COALESCE(new_role,'')) AS detail,
|
||||
'permissions' AS source
|
||||
FROM permission_audit_log
|
||||
WHERE (?='' OR CAST(performed_by AS TEXT)=?)
|
||||
AND (?='' OR action LIKE ?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
||||
).fetchall()
|
||||
if source == "permissions":
|
||||
return rows
|
||||
perm = [dict(r) for r in rows]
|
||||
else:
|
||||
perm = []
|
||||
if source in ("sso", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, user_id AS actor,
|
||||
('sso_' || provider_type || '_' ||
|
||||
CASE success WHEN 1 THEN 'success' ELSE 'failure' END) AS action,
|
||||
provider_name AS resource, ip_address AS ip,
|
||||
COALESCE(error_message, sso_identifier, '') AS detail,
|
||||
'sso' AS source
|
||||
FROM sso_login_history
|
||||
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, limit, offset)).fetchall()
|
||||
if source == "sso":
|
||||
return rows
|
||||
sso = [dict(r) for r in rows]
|
||||
else:
|
||||
sso = []
|
||||
merged = sorted(api + perm + sso, key=lambda d: str(d.get("at") or ""),
|
||||
reverse=True)
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
@router.get("/api/v2/audit/logs")
|
||||
async def audit_logs(request: Request):
|
||||
_admin_user(request)
|
||||
qp = request.query_params
|
||||
source = (qp.get("source") or "all").lower()
|
||||
if source not in ("all", "api", "permissions", "sso"):
|
||||
raise HTTPException(400, "source must be all|api|permissions|sso")
|
||||
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
|
||||
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
|
||||
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
|
||||
if qp.get("format") == "csv":
|
||||
import csv
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
|
||||
"resource", "ip", "detail"])
|
||||
writer.writeheader()
|
||||
for r in rows[:10000]:
|
||||
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
|
||||
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
|
||||
headers={"Content-Disposition":
|
||||
"attachment; filename=audit.csv"})
|
||||
return JSONResponse(content={"logs": rows, "source": source,
|
||||
"limit": limit, "offset": offset})
|
||||
+176
-6
@@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
.sso-btn{border-color:rgba(35,131,226,.5);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<div class="oauth-section" id="oauth-section">
|
||||
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||
</div>
|
||||
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
|
||||
<div class="oauth-section" id="sso-section" style="display:none">
|
||||
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<div id="sso-buttons"></div>
|
||||
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Show session expired banner if ?expired=1 in URL
|
||||
@@ -101,6 +108,38 @@ let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
||||
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
|
||||
(async function loadSsoProviders(){
|
||||
try{
|
||||
const r = await fetch('/api/v2/sso/providers');
|
||||
if(!r.ok) return;
|
||||
const d = await r.json();
|
||||
const providers = d.providers || [];
|
||||
if(!providers.length) return;
|
||||
const wrap = document.getElementById('sso-buttons');
|
||||
providers.forEach(function(p){
|
||||
const b = document.createElement('button');
|
||||
b.className = 'oauth-btn sso-btn';
|
||||
b.style.marginBottom = '8px';
|
||||
b.title = 'Sign in with ' + (p.name || 'SSO');
|
||||
b.onclick = function(){ window.location = p.login_url; };
|
||||
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
|
||||
b.appendChild(icon); b.appendChild(label);
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
document.getElementById('sso-section').style.display = 'block';
|
||||
if(d.sso_only){
|
||||
// Local auth is refused server-side too — don't show a dead form.
|
||||
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
|
||||
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
|
||||
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
|
||||
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
|
||||
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -191,6 +230,16 @@ async def register(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
# SSO-only instance (v6.7.0): local registration is refused — accounts are
|
||||
# auto-provisioned by the IdP instead (admins still come from Settings).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only():
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"error": "Registration is disabled — sign in with your organization SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
@@ -260,13 +309,41 @@ async def local_login(request: Request):
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
# Successful local login — SSO-only instances keep a way in for admins
|
||||
# only (every other account must use the IdP, design §7.1).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only() and not ud.get("is_admin"):
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled on this instance — sign in with SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
|
||||
if not ud.get("is_admin"):
|
||||
with get_conn() as conn:
|
||||
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
|
||||
if dom:
|
||||
enforced = conn.execute(
|
||||
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
|
||||
" AND enforce_sso=1", (dom,)).fetchone()
|
||||
if enforced:
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled for your domain — sign in with SSO"},
|
||||
status_code=403)
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
|
||||
from app.services import two_factor as _2fa
|
||||
if _2fa.is_enabled(ud["id"]):
|
||||
return JSONResponse({"status": "2fa_required",
|
||||
"pending": _2fa.mint_pending(ud["id"])})
|
||||
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
@@ -373,9 +450,21 @@ async def callback(
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
async def logout(request: Request):
|
||||
"""Clear session and redirect to login page.
|
||||
|
||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||
is configured (the actual cookie clearing happens on the SLO route).
|
||||
"""
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
local_target = "/auth/login?provider=local"
|
||||
|
||||
if user and user.get("_sso_name_id"):
|
||||
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
|
||||
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
|
||||
|
||||
response = RedirectResponse(url=local_target, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
@@ -389,6 +478,87 @@ async def current_user(request: Request):
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
if not _2fa.verify_code(user_id, body.get("code", "")):
|
||||
return JSONResponse({"error": "Invalid code"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
ud = dict(row)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _session_user_or_401(request: Request) -> dict:
|
||||
from fastapi import HTTPException
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid code — secret not activated"},
|
||||
status_code=400)
|
||||
return {"status": "enabled", "backup_codes": codes}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
return {"status": "disabled"}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
@@ -403,4 +573,4 @@ def _log_login(user_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_log_login")
|
||||
|
||||
+153
-4
@@ -4,14 +4,28 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import get_page_context, run_automation
|
||||
from app.services.automations import (
|
||||
get_page_context,
|
||||
get_steps,
|
||||
press_button,
|
||||
run_automation,
|
||||
validate_step,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
|
||||
|
||||
def _require_session(request: Request) -> None:
|
||||
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
@@ -69,7 +83,7 @@ async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
by = user["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
@@ -172,3 +186,138 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
|
||||
|
||||
STEP_SECRET_FIELDS = {"webhook_url"}
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _get_auto(auto_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _auto_404():
|
||||
# NOTE: return (not raise) — the global 404 handler redirects non-/api
|
||||
# paths to /workspaces, which TestClient follows into a 200.
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Automation not found"}, status_code=404)
|
||||
|
||||
|
||||
def _encrypt_step_config(config: dict) -> dict:
|
||||
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
cfg = dict(config or {})
|
||||
for field in STEP_SECRET_FIELDS:
|
||||
if field in cfg and cfg[field]:
|
||||
val = str(cfg[field])
|
||||
if not val.startswith("gAAAAA"):
|
||||
cfg[field] = encrypt_secret(val)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
with get_conn() as conn:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
|
||||
(auto_id,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
|
||||
" VALUES (?,?,?,?)",
|
||||
(auto_id, kind, int(body.get("position", pos)),
|
||||
json.dumps(_encrypt_step_config(config))))
|
||||
conn.commit()
|
||||
step_id = cur.lastrowid
|
||||
return {"id": step_id, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Step not found"}, status_code=404)
|
||||
kind = body.get("kind", row["kind"])
|
||||
try:
|
||||
config = body.get("config", json.loads(row["config_json"] or "{}"))
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
config = {}
|
||||
validate_step(kind, config if isinstance(config, dict) else {})
|
||||
conn.execute(
|
||||
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
|
||||
(kind, int(body.get("position", row["position"])),
|
||||
json.dumps(_encrypt_step_config(config)), step_id))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "trigger_mode": mode}
|
||||
|
||||
|
||||
@router.post("/api/automations/press-button")
|
||||
async def press_button_endpoint(request: Request):
|
||||
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
row_id = int(body.get("row_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
|
||||
prop_ref = body.get("property", body.get("property_id", ""))
|
||||
if not prop_ref:
|
||||
raise HTTPException(status_code=400, detail="property required")
|
||||
user = _current_user(request)
|
||||
try:
|
||||
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return result
|
||||
|
||||
+98
-34
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@@ -694,7 +695,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
@@ -780,7 +781,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
@@ -803,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -907,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -981,7 +982,7 @@ async def add_favorite(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@@ -996,7 +997,7 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
@@ -1031,7 +1032,7 @@ async def publish_page(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("publish_page")
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
|
||||
|
||||
@@ -1047,7 +1048,7 @@ async def unpublish_page(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("unpublish_page")
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1068,7 +1069,7 @@ async def restore_page(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@@ -1083,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
@@ -1212,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
@@ -1239,8 +1240,8 @@ async def board_view(
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
@@ -1384,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
@@ -1416,11 +1420,11 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1456,7 +1460,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown"})
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -1522,7 +1527,8 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1726,7 +1732,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
@@ -1883,7 +1889,11 @@ async def import_file(request: Request):
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card."""
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1891,11 +1901,62 @@ async def og_metadata(request: Request):
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
data = await fetch_og_metadata(url)
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
@@ -1979,7 +2040,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
@@ -1990,8 +2051,8 @@ async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
@@ -2084,12 +2145,15 @@ async def sync_project(owner: str, repo: str):
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
(board_id, issue["number"], col),
|
||||
)
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
|
||||
@@ -107,6 +107,12 @@ async def add_comment(request: Request, page_id: int):
|
||||
comment_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
# v7.3.0: commenting implies following — the author gets the
|
||||
# (throttled) page.updated notifications like any other follower.
|
||||
from app.services import wiki as wiki_svc
|
||||
wiki_svc.ensure_follow(page_id, uid, conn=conn)
|
||||
conn.commit()
|
||||
|
||||
# Notify users @-mentioned in the comment (skip the author).
|
||||
url = _page_url(page_id)
|
||||
title = f"New comment on “{page['title']}”"
|
||||
@@ -123,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
@@ -153,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@@ -184,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
+185
-26
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
@@ -42,23 +43,22 @@ def _session_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
@@ -373,20 +373,35 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in rows:
|
||||
ncur = conn.execute(
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"]),
|
||||
tuples,
|
||||
)
|
||||
prop_map[p["id"]] = ncur.lastrowid
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
@@ -1898,6 +1913,71 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
@@ -1912,9 +1992,14 @@ async def view_collection(request: Request, collection_id: int, view_type: str =
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
@@ -2188,27 +2273,101 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN."""
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages:
|
||||
labels.append(p["title"][:30])
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
val = 0
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0
|
||||
values.append(val if val else 1)
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
@@ -2223,7 +2382,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{collection["name"]}',
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
@@ -2231,7 +2390,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{len(pages)} entries</p>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
+136
-64
@@ -2,8 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -52,7 +53,7 @@ def _get_user_or_redirect(request: Request):
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
@@ -88,7 +89,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
@@ -179,7 +180,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
@@ -193,7 +194,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
@@ -206,7 +207,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
@@ -252,10 +253,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -283,10 +283,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -312,10 +311,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -437,8 +435,8 @@ async def view_page_root(request: Request, page_id: int):
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -451,8 +449,8 @@ async def accounts_page(request: Request):
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
@@ -489,6 +487,7 @@ async def help_page(request: Request):
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
@@ -590,6 +589,16 @@ FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
@@ -645,8 +654,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -678,11 +687,20 @@ def _get_user_id(request: Request) -> int:
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
@@ -691,13 +709,18 @@ async def update_profile(request: Request):
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
from app.password_utils import hash_password
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -706,7 +729,7 @@ async def update_password(request: Request):
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -719,7 +742,7 @@ async def generate_token(request: Request):
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
@@ -744,14 +767,14 @@ async def dashboard(
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
@@ -766,7 +789,7 @@ async def dashboard(
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
@@ -779,7 +802,7 @@ async def dashboard(
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
@@ -796,8 +819,8 @@ async def dashboard(
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
@@ -809,8 +832,8 @@ async def dashboard(
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -828,8 +851,8 @@ async def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -890,9 +913,18 @@ async def list_workspace_projects(request: Request):
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
@@ -911,7 +943,7 @@ async def list_workspace_projects(request: Request):
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
@@ -1005,8 +1037,8 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1127,9 +1159,21 @@ def _file_page_disk_path(page: dict):
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
async def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1150,13 +1194,15 @@ async def download_page_file(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
async def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1440,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), item_id),
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -1504,7 +1550,8 @@ async def upload_local_workspace_file(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1521,7 +1568,12 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1542,10 +1594,14 @@ async def upload_local_workspace_file(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
@@ -1577,7 +1633,8 @@ async def upload_local_workspace_folder(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1599,7 +1656,12 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1658,9 +1720,13 @@ async def upload_local_workspace_folder(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
@@ -1710,8 +1776,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1813,8 +1879,8 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1862,7 +1928,14 @@ async def serve_avatar_file(filename: str):
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
filepath = Path("/data/avatars") / filename
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
@@ -2025,7 +2098,7 @@ async def add_item_tag(request: Request, item_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
@@ -2131,9 +2204,8 @@ async def sidebar_workspace_tree(request: Request):
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2169,7 +2241,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
@@ -2190,7 +2262,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2210,7 +2282,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
|
||||
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
|
||||
+15
-5
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>"}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("ws_page")
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
|
||||
@@ -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}
|
||||
@@ -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")}
|
||||
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("revoke_session")
|
||||
return {"status": "revoked"}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("share_page")
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("publish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("unpublish_page")
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
source_type=excluded.source_type,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
|
||||
"status": "tracked",
|
||||
"user_id": user["id"],
|
||||
"page_id": page_id,
|
||||
"accessed_at": datetime.utcnow().isoformat(),
|
||||
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
"""FlowDeck — Sites & public Forms (v6.8.0).
|
||||
|
||||
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
|
||||
password/expiry gating, SEO + view stats, and anonymous collection forms
|
||||
(/f/<token>) with rate limiting, validation and notifications.
|
||||
|
||||
Auth: session cookie first, Bearer token fallback (api_tokens,
|
||||
extension_devices, legacy user_tokens) via api_v2_helpers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password, verify_password
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["sites"])
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
||||
_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$")
|
||||
|
||||
# In-memory rate limiting for anonymous form posts: ip -> (window_start, count).
|
||||
_form_rate: dict[str, tuple[float, int]] = {}
|
||||
_FORM_RATE_MAX = 20
|
||||
_FORM_RATE_WINDOW = 3600.0
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
def _check_slug(slug: str) -> None:
|
||||
if not _SLUG_RE.match(slug or ""):
|
||||
raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.")
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
"""Session-first auth, Bearer fallback. Enforces scope for Bearer tokens."""
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _site_auth_cookie(site_id: int) -> str:
|
||||
return f"site_auth_{site_id}"
|
||||
|
||||
|
||||
def _site_unlocked(request: Request, site: dict) -> bool:
|
||||
if not site.get("password_hash"):
|
||||
return True
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
try:
|
||||
val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400)
|
||||
return val == site["id"]
|
||||
except BadSignature:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _site_expired(site: dict) -> bool:
|
||||
exp = site.get("expires_at")
|
||||
if not exp:
|
||||
return False
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp() < time.time()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None:
|
||||
row = None
|
||||
if slug:
|
||||
row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone()
|
||||
elif host:
|
||||
row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _site_pages(conn, site_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position
|
||||
FROM site_pages sp JOIN pages p ON p.id = sp.page_id
|
||||
WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='')
|
||||
ORDER BY sp.position, p.id""",
|
||||
(site_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _find_site_page(pages: list[dict], ref: str) -> dict | None:
|
||||
ref = (ref or "").strip()
|
||||
if ref.isdigit():
|
||||
for p in pages:
|
||||
if p["id"] == int(ref):
|
||||
return p
|
||||
for p in pages:
|
||||
if p["slug"] == ref:
|
||||
return p
|
||||
# slug with -<id> suffix fallback
|
||||
m = re.search(r"-(\d+)$", ref)
|
||||
if m:
|
||||
for p in pages:
|
||||
if p["id"] == int(m.group(1)):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _render_page_html(page: dict) -> str:
|
||||
"""Render a pages row to HTML (blocks → public renderer, else <pre>)."""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
try:
|
||||
from app.routers import dashboard as _dash
|
||||
blocks = json.loads(page["content"])
|
||||
try:
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
logger.exception("_render_page_html")
|
||||
titles: dict = {}
|
||||
try:
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as _c:
|
||||
titles = token_labels(_c, page["content"])
|
||||
except Exception:
|
||||
titles = {}
|
||||
return _dash._render_blocks_public(blocks, titles)
|
||||
except Exception:
|
||||
return f"<p>{html.escape(str(page.get('content', '')))}</p>"
|
||||
if page.get("content"):
|
||||
return (
|
||||
"<pre style='white-space:pre-wrap;font-family:system-ui;"
|
||||
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
|
||||
)
|
||||
return "<p style='color:#999'>Empty page.</p>"
|
||||
|
||||
|
||||
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
|
||||
body_html: str, noindex: bool = False) -> str:
|
||||
nav = "".join(
|
||||
f"<a href='/s/{site['slug']}/{p['slug']}'"
|
||||
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
|
||||
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
|
||||
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
|
||||
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
|
||||
for p in pages
|
||||
)
|
||||
robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow"
|
||||
desc = html.escape((site.get("title") or title)[:160])
|
||||
theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff"
|
||||
theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222"
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="robots" content="{robots}">
|
||||
<meta name="description" content="{desc}">
|
||||
<meta property="og:title" content="{html.escape(title)}">
|
||||
<meta property="og:description" content="{desc}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
|
||||
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
|
||||
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
|
||||
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
|
||||
<body><div class="layout"><nav class="nav">
|
||||
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
|
||||
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
|
||||
<main class="main">{body_html}</main></div></body></html>"""
|
||||
|
||||
|
||||
def _track_view(site_id: int) -> None:
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""",
|
||||
(site_id, day),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_track_view")
|
||||
|
||||
|
||||
def _form_config(conn, collection_id: int) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
try:
|
||||
cfg = json.loads(row["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {"id": row["id"], "name": row["name"], "config": cfg}
|
||||
|
||||
|
||||
def _check_form_rate(ip: str) -> None:
|
||||
now = time.time()
|
||||
start, count = _form_rate.get(ip, (now, 0))
|
||||
if now - start > _FORM_RATE_WINDOW:
|
||||
_form_rate[ip] = (now, 1)
|
||||
return
|
||||
if count >= _FORM_RATE_MAX:
|
||||
raise HTTPException(429, "Too many submissions. Try again later.")
|
||||
_form_rate[ip] = (start, count + 1)
|
||||
|
||||
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
slug = (body.get("slug") or "").strip().lower() or None
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Root page not found")
|
||||
if not slug:
|
||||
slug = _slugify(page["title"])
|
||||
base, i = slug, 1
|
||||
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
slug = f"{base}-{i}"
|
||||
i += 1
|
||||
else:
|
||||
_check_slug(slug)
|
||||
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
theme = body.get("theme", "dark")
|
||||
if theme not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
custom_domain = (body.get("custom_domain") or "").strip() or None
|
||||
if custom_domain and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
expires_at = body.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
|
||||
expires_at, noindex, analytics_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(slug, root_page_id, body.get("title") or page["title"],
|
||||
theme, custom_domain, expires_at,
|
||||
1 if body.get("noindex") else 0,
|
||||
(body.get("analytics_id") or "")[:120], user["id"]),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
|
||||
(site_id, root_page_id),
|
||||
)
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
|
||||
return JSONResponse(status_code=201, content=row_to_dict(site))
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
resp = JSONResponse([row_to_dict(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
pages = _site_pages(conn, site_id)
|
||||
out = row_to_dict(row)
|
||||
out["pages"] = pages
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
updates: dict = {}
|
||||
if "title" in body:
|
||||
updates["title"] = str(body["title"] or "")[:200]
|
||||
if "theme" in body:
|
||||
if body["theme"] not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
updates["theme"] = body["theme"]
|
||||
if "slug" in body and body["slug"] != site["slug"]:
|
||||
_check_slug(str(body["slug"]).lower())
|
||||
if conn.execute(
|
||||
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = str(body["slug"]).lower()
|
||||
if "custom_domain" in body:
|
||||
dom = (body["custom_domain"] or "").strip() or None
|
||||
if dom and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
updates["custom_domain"] = dom
|
||||
if "expires_at" in body:
|
||||
if body["expires_at"]:
|
||||
try:
|
||||
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
updates["expires_at"] = body["expires_at"]
|
||||
if "noindex" in body:
|
||||
updates["noindex"] = 1 if body["noindex"] else 0
|
||||
if "analytics_id" in body:
|
||||
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
|
||||
if "password" in body:
|
||||
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
|
||||
if updates:
|
||||
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
|
||||
return row_to_dict(site)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "site.delete", "site", site_id, "", request)
|
||||
return {"status": "deleted", "id": site_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
|
||||
(site_id, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
pages = _site_pages(conn, site_id)
|
||||
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
|
||||
return {"site_id": site_id, "pages": pages}
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if page_id == row["root_page_id"]:
|
||||
raise HTTPException(400, "Cannot remove the root page")
|
||||
conn.execute(
|
||||
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
|
||||
return {"status": "removed", "site_id": site_id, "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
rows = conn.execute(
|
||||
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
|
||||
(site_id, days),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
return {"site_id": site_id, "total_views": total,
|
||||
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
|
||||
|
||||
|
||||
# ── Public site rendering ──────────────────────────────────────────────────
|
||||
|
||||
def _public_guard(site: dict, request: Request):
|
||||
if _site_expired(site):
|
||||
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
|
||||
if site.get("password_hash") and not _site_unlocked(request, site):
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<form method="post" action="/s/{site['slug']}/auth">
|
||||
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
|
||||
<input type="password" name="password" placeholder="Password"
|
||||
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
|
||||
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
|
||||
status_code=401,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
pages = _site_pages(conn, site["id"])
|
||||
base = str(request.base_url).rstrip("/")
|
||||
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
|
||||
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
|
||||
]
|
||||
return PlainTextResponse(
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
|
||||
f"{''.join(urls)}</urlset>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
target = _find_site_page(pages, page_ref)
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.post("/s/{slug}/auth")
|
||||
async def public_site_auth(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site:
|
||||
return JSONResponse({"detail": "Site not found"}, status_code=404)
|
||||
if not site.get("password_hash"):
|
||||
return {"status": "public"}
|
||||
ctype = request.headers.get("content-type", "")
|
||||
password = ""
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
password = (await request.json()).get("password", "")
|
||||
except Exception:
|
||||
logger.exception("public_site_auth")
|
||||
password = ""
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
except Exception:
|
||||
logger.exception("public_site_auth")
|
||||
password = ""
|
||||
if not verify_password(password or "", site["password_hash"] or ""):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
resp = JSONResponse({"status": "unlocked"})
|
||||
resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]),
|
||||
httponly=True, samesite="lax", max_age=86400, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/collections/{collection_id}/form")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
if "enabled" in body:
|
||||
cfg["enabled"] = bool(body["enabled"])
|
||||
for key in ("title", "success_message"):
|
||||
if key in body:
|
||||
cfg[key] = str(body[key] or "")[:300]
|
||||
for key in ("fields", "required", "notify_user_ids"):
|
||||
if key in body and isinstance(body[key], list):
|
||||
cfg[key] = body[key][:50]
|
||||
if "public_token" in body and body["public_token"]:
|
||||
tok = str(body["public_token"])
|
||||
if not _FORM_TOKEN_RE.match(tok):
|
||||
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
|
||||
cfg["public_token"] = tok
|
||||
if cfg.get("enabled") and not cfg.get("public_token"):
|
||||
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
|
||||
conn.execute(
|
||||
"UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(cfg), collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "form.config", "collection", collection_id, "", request)
|
||||
return {"collection_id": collection_id, "form": cfg}
|
||||
|
||||
|
||||
def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
_ = row
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
inputs = ""
|
||||
for name in fields:
|
||||
prop = next((p for p in props if p["name"] == name), None)
|
||||
ptype = (prop or {}).get("prop_type", "text")
|
||||
itype = {"number": "number", "email": "email", "url": "url",
|
||||
"date": "date", "phone": "tel"}.get(ptype, "text")
|
||||
req = "required" if name in required else ""
|
||||
if ptype in ("select", "status") and prop:
|
||||
try:
|
||||
opts = json.loads(prop.get("options_json") or "[]")
|
||||
except Exception:
|
||||
opts = []
|
||||
opts_html = "".join(
|
||||
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
|
||||
elif ptype == "checkbox":
|
||||
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
|
||||
f"{html.escape(name)}</label>")
|
||||
else:
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
|
||||
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
|
||||
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
|
||||
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
|
||||
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
|
||||
</head><body>{chrome}
|
||||
<form method="post" action="/f/{token}">
|
||||
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
|
||||
{inputs}<button>Submit</button></form></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/f/{token}")
|
||||
async def submit_form(request: Request, token: str):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_form_rate(ip or "unknown")
|
||||
ctype = request.headers.get("content-type", "")
|
||||
data: dict = {}
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("__hp"):
|
||||
raise HTTPException(400, "Spam detected")
|
||||
with get_conn() as conn:
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
if not target:
|
||||
# NOTE: return (not raise) — the global 404 handler redirects
|
||||
# non-/api paths to /workspaces, which would turn this into a 200.
|
||||
return JSONResponse({"detail": "Form not found"}, status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
by_name = {p["name"]: p for p in props}
|
||||
fields = cfg.get("fields") or list(by_name)[:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
values: dict = {}
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
raw = data.get(name, "")
|
||||
if prop["prop_type"] == "checkbox":
|
||||
raw = True if raw in (True, "on", "true", "1", "checked") else False
|
||||
if name in required and (raw is None or raw == "" or raw is False):
|
||||
raise HTTPException(400, f"Field required: {name}")
|
||||
values[str(prop["id"])] = raw
|
||||
# Validate via property_types.validate_property_rule
|
||||
try:
|
||||
from app.services.property_types import validate_property_rule
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
ok, _msg = validate_property_rule(
|
||||
prop.get("prop_type", "text"), values.get(str(prop["id"])),
|
||||
prop.get("validation_json") or prop.get("options_json") or "")
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("submit_form")
|
||||
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
||||
VALUES (?, ?, ?)""",
|
||||
(coll["id"], title or "Form response", json.dumps(values)),
|
||||
)
|
||||
row_id = cur.lastrowid
|
||||
ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest()
|
||||
conn.execute(
|
||||
"INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)",
|
||||
(coll["id"], row_id, ip_hash),
|
||||
)
|
||||
conn.commit()
|
||||
notify_ids = cfg.get("notify_user_ids") or []
|
||||
# Notify (never throws the submission)
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
for uid in notify_ids[:20]:
|
||||
try:
|
||||
create_notification(int(uid), None, "form_response",
|
||||
f"New response: {coll['name']}",
|
||||
f"{title}", "collection", coll["id"],
|
||||
f"/db/{coll['id']}")
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("submit_form")
|
||||
try:
|
||||
from app.services.automations import fire_event as _fire
|
||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||
except Exception:
|
||||
logger.exception("submit_form")
|
||||
if "application/json" in ctype:
|
||||
return {"status": "ok", "row_id": row_id,
|
||||
"message": cfg.get("success_message") or "Merci !"}
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
# used by tests to reset the anonymous rate limiter
|
||||
def _reset_form_rate() -> None:
|
||||
_form_rate.clear()
|
||||
|
||||
|
||||
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
|
||||
__all__ = ["router", "get_bearer_user"]
|
||||
@@ -0,0 +1,656 @@
|
||||
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
|
||||
|
||||
Two families of routes:
|
||||
|
||||
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
|
||||
ACS callback, SP metadata, Single Logout). The callback endpoints are
|
||||
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
|
||||
single-use ``sso_requests`` relay token + full assertion validation.
|
||||
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
|
||||
with write scope), consumed by Settings → Admin → SSO / Enterprise.
|
||||
|
||||
Every attempt — success or rejection — lands in ``sso_login_history``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
DEFAULT_NEXT = "/workspaces"
|
||||
|
||||
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
|
||||
_RATE_WINDOW = 60.0
|
||||
_RATE_MAX = 5
|
||||
_rate_store: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
|
||||
from app.config import settings
|
||||
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = f"{bucket}:{ip}"
|
||||
now = time.time()
|
||||
window, count = _rate_store.get(key, (0.0, 0))
|
||||
if now - window > _RATE_WINDOW:
|
||||
_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= _RATE_MAX:
|
||||
return False
|
||||
_rate_store[key] = (window, count + 1)
|
||||
return True
|
||||
|
||||
|
||||
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
|
||||
"""Small standalone error/info page (same styling as the login page)."""
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>FlowDeck — {title}</title><style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
|
||||
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
|
||||
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
|
||||
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
def _sso_config_or_error() -> dict | None:
|
||||
cfg = sso.get_sso_config()
|
||||
return sso.normalize_config(cfg) if cfg else None
|
||||
|
||||
|
||||
def _session_cookie(user_data: dict, request: Request):
|
||||
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
|
||||
return SessionManager.create_session(user_data, request)
|
||||
|
||||
|
||||
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
|
||||
provider_type = (cfg or {}).get("provider_type", "saml")
|
||||
sso.log_sso_login(
|
||||
user_id=None,
|
||||
provider_type=provider_type,
|
||||
provider_name=(cfg or {}).get("name") or "SSO",
|
||||
identifier=identifier,
|
||||
request=request,
|
||||
success=False,
|
||||
error=message,
|
||||
)
|
||||
logger.warning("SSO login rejected: %s", message)
|
||||
safe = (
|
||||
message.replace("&", "&").replace("<", "<").replace(">", ">")[:400]
|
||||
)
|
||||
return _page(
|
||||
"SSO sign-in failed",
|
||||
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page(
|
||||
"SAML not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
|
||||
csrf_token = secrets.token_hex(16)
|
||||
# The id is only known after building the request, so build it first with a
|
||||
# placeholder relay state, then re-issue with the real one? python3-saml
|
||||
# builds the AuthnRequest inside login(); we instead create the row right
|
||||
# after login() returns the URL — but the RelayState is already embedded.
|
||||
# So: generate the request id ourselves is not possible → build the URL,
|
||||
# then patch the RelayState by rebuilding with the known id.
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
|
||||
authn_id = provisional[1]
|
||||
relay = f"{authn_id}.{csrf_token}"
|
||||
sso.create_request(
|
||||
"saml_authn",
|
||||
request_id=authn_id,
|
||||
relay_state=csrf_token,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
# Replace the placeholder RelayState with the real token (same SAMLRequest).
|
||||
parsed = urlparse(provisional[0])
|
||||
params = parse_qs(parsed.query)
|
||||
params["RelayState"] = [relay]
|
||||
flat = [(k, v) for k, values in params.items() for v in values]
|
||||
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/auth/saml/callback")
|
||||
async def saml_callback(request: Request):
|
||||
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
|
||||
if not _rate_ok(request, "saml-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
form = await request.form()
|
||||
saml_response = str(form.get("SAMLResponse") or "")
|
||||
relay_state = str(form.get("RelayState") or "")
|
||||
if not saml_response:
|
||||
return _login_error("Missing SAMLResponse", cfg=None, request=request)
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _login_error("SAML is not configured", cfg=None, request=request)
|
||||
|
||||
authn_id, _, csrf_token = relay_state.partition(".")
|
||||
pending = sso.peek_request("saml_authn", authn_id)
|
||||
if not pending and sso.was_consumed("saml_authn", authn_id):
|
||||
# Same assertion twice: the single-use row is already spent.
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
if not pending or not csrf_token or not secrets.compare_digest(
|
||||
pending.get("relay_state", ""), csrf_token
|
||||
):
|
||||
return _login_error(
|
||||
"Unknown or expired login request (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
try:
|
||||
identity = saml_provider.process_response(
|
||||
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
|
||||
)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
|
||||
|
||||
# Single-use: the same AuthnRequest id can never authenticate twice.
|
||||
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
|
||||
if not consumed:
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
|
||||
claims = sso.identity_from_saml(identity, cfg)
|
||||
identifier = sso.sso_identifier_field(claims)
|
||||
try:
|
||||
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="saml",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
user_data = dict(user)
|
||||
user_data["_sso_name_id"] = identity.name_id
|
||||
user_data["_sso_session_index"] = identity.session_index
|
||||
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(user_data, request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
async def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
try:
|
||||
xml = saml_provider.metadata_xml(request, cfg)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _page("Metadata error", f"<p>{err}</p>", status=500)
|
||||
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
|
||||
|
||||
|
||||
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
|
||||
|
||||
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
|
||||
local session);
|
||||
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
|
||||
of our own SLO, or a LogoutRequest issued by the IdP).
|
||||
"""
|
||||
form = dict(await request.form()) if request.method == "POST" else {}
|
||||
query = dict(request.query_params)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
|
||||
if payload:
|
||||
try:
|
||||
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SLO processing failed: %s", err)
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
if errors:
|
||||
return _login_error(
|
||||
"; ".join(errors)[:300], cfg=cfg, request=request
|
||||
)
|
||||
response = RedirectResponse(url or next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
# SP-initiated
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
|
||||
try:
|
||||
logout_url = saml_provider.build_logout_url(
|
||||
request, cfg,
|
||||
return_to=sso.safe_next_path(next),
|
||||
name_id=user.get("_sso_name_id", ""),
|
||||
session_index=user.get("_sso_session_index", ""),
|
||||
)
|
||||
# Keep the cookie-clearing headers built above: hand the browser
|
||||
# to the IdP with our local session already dead.
|
||||
response = RedirectResponse(logout_url, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SP-initiated SLO failed: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/saml/logout")
|
||||
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/saml/logout")
|
||||
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
# ═════════════════════════════════ OIDC ═══════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/oidc/login")
|
||||
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""Redirect to the OIDC provider (authorization code + PKCE)."""
|
||||
if not _rate_ok(request, "oidc"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _page(
|
||||
"OIDC not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
nonce = secrets.token_hex(16)
|
||||
verifier, challenge = oidc_provider.pkce_pair()
|
||||
sso.create_request(
|
||||
"oidc",
|
||||
request_id=state,
|
||||
relay_state=nonce,
|
||||
code_verifier=verifier,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
url = oidc_provider.build_authorize_url(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
scope=cfg.get("scope") or "openid profile email",
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge=challenge,
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request):
|
||||
"""OIDC callback: exchange the code, validate the ID token, open a session."""
|
||||
if not _rate_ok(request, "oidc-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
params = dict(request.query_params)
|
||||
if request.method == "POST":
|
||||
params.update({k: str(v) for k, v in (await request.form()).items()})
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _login_error("OIDC is not configured", cfg=None, request=request)
|
||||
|
||||
if params.get("error"):
|
||||
return _login_error(
|
||||
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
code, state = params.get("code", ""), params.get("state", "")
|
||||
pending = sso.consume_request("oidc", state)
|
||||
if not code or not pending:
|
||||
return _login_error(
|
||||
"Unknown or expired OIDC state (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
tokens = await oidc_provider.exchange_code(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
client_secret=sso.client_secret_value(cfg),
|
||||
code=code,
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
code_verifier=pending.get("code_verifier", ""),
|
||||
)
|
||||
jwks = await _fetch_jwks(doc)
|
||||
claims = oidc_provider.validate_id_token(
|
||||
tokens.get("id_token", ""),
|
||||
issuer=cfg["issuer_url"],
|
||||
client_id=cfg["client_id"],
|
||||
nonce=pending.get("relay_state", ""),
|
||||
jwks=jwks,
|
||||
)
|
||||
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
|
||||
|
||||
merged = {**claims, **userinfo}
|
||||
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
|
||||
identifier = sso.sso_identifier_field(identity)
|
||||
try:
|
||||
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="oidc",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(dict(user), request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
async def oidc_callback(request: Request):
|
||||
"""OIDC callback (GET, authorization code in the query string)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/callback")
|
||||
async def oidc_callback_post(request: Request):
|
||||
"""OIDC callback (POST, form_post response mode)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as err:
|
||||
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
|
||||
if not isinstance(data, dict) or not data.get("keys"):
|
||||
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
|
||||
return data
|
||||
|
||||
|
||||
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Local logout + RP-initiated logout at the provider when supported."""
|
||||
cfg = _sso_config_or_error()
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if cfg and cfg["provider_type"] == "oidc":
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
end_session = doc.get("end_session_endpoint")
|
||||
if end_session:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
qs = urlencode({
|
||||
"client_id": cfg["client_id"],
|
||||
"post_logout_redirect_uri": external_base_url(request) + next,
|
||||
})
|
||||
sep = "&" if "?" in end_session else "?"
|
||||
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
|
||||
except oidc_provider.OIDCError as err:
|
||||
logger.debug("RP-initiated logout skipped: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/logout")
|
||||
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/logout")
|
||||
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (POST)."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
exempted in the middleware, so the check lives here for this router.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth_header[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
scopes = user.get("_token_scopes") or ""
|
||||
need = "write" if write else "read"
|
||||
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
if not cookie or not header or not secrets.compare_digest(cookie, header):
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
async def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
return {"providers": [], "sso_only": False}
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
base = external_base_url(request)
|
||||
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
|
||||
return {
|
||||
"providers": [{
|
||||
"type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"icon": "🏢",
|
||||
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
|
||||
}],
|
||||
"sso_only": bool(cfg.get("sso_only")),
|
||||
"base_url": base,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
|
||||
f"provider={saved.get('provider_type')}", request)
|
||||
return sso.public_config_view(saved)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
|
||||
return {"status": "ok", "disabled": removed}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
|
||||
).fetchall()
|
||||
cfg = _sso_config_or_error()
|
||||
return {
|
||||
"workspaces": [dict(r) for r in rows],
|
||||
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
|
||||
"sso_only": bool((cfg or {}).get("sso_only")),
|
||||
"provisioned_users": sso.provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
|
||||
h.sso_identifier, h.ip_address, h.success, h.error_message,
|
||||
h.created_at
|
||||
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
|
||||
ORDER BY h.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ident = d.get("sso_identifier") or ""
|
||||
if "|" in ident: # drop the stored group list from the UI payload
|
||||
d["sso_identifier"] = ident.split("|", 1)[0]
|
||||
d["success"] = bool(d["success"])
|
||||
out.append(d)
|
||||
return {"history": out}
|
||||
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
@@ -250,7 +249,7 @@ async def extensions_page(request: Request):
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("extensions_page")
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import workers as worker_service
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["workers"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _row_to_api(row) -> dict:
|
||||
d = row_to_dict(row)
|
||||
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
worker_service.validate_code(code)
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
slug = worker_service.unique_slug(body.get("slug") or name)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
|
||||
shared, daily_budget_s, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(slug, body.get("workspace_id"), name, code,
|
||||
(body.get("schedule_cron") or "")[:60],
|
||||
1 if body.get("shared") else 0,
|
||||
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
|
||||
user["id"]))
|
||||
conn.commit()
|
||||
wid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "worker.create", "worker", wid, slug, request)
|
||||
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)).fetchall()
|
||||
resp = JSONResponse([_row_to_api(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
out = _row_to_api(row)
|
||||
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
|
||||
or user.get("is_admin")):
|
||||
out["code_py"] = row["code_py"]
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can update this worker")
|
||||
updates: dict = {}
|
||||
if "name" in body:
|
||||
updates["name"] = str(body["name"] or "")[:200]
|
||||
if "code_py" in body:
|
||||
try:
|
||||
worker_service.validate_code(body["code_py"] or "")
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
updates["code_py"] = body["code_py"] or ""
|
||||
if "schedule_cron" in body:
|
||||
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
|
||||
if "shared" in body:
|
||||
updates["shared"] = 1 if body["shared"] else 0
|
||||
if "daily_budget_s" in body:
|
||||
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
|
||||
if "slug" in body and body["slug"] != row["slug"]:
|
||||
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
|
||||
raise HTTPException(400, "Invalid slug")
|
||||
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
|
||||
(body["slug"], worker_id)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = body["slug"]
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), worker_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
|
||||
return _row_to_api(row)
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can delete this worker")
|
||||
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "worker.delete", "worker", worker_id, "", request)
|
||||
return {"status": "deleted", "id": worker_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/run")
|
||||
async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if (row["created_by"] != user["id"] and not row["shared"]
|
||||
and not user.get("is_admin")):
|
||||
raise HTTPException(403, "Worker is private")
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
out = await loop.run_in_executor(
|
||||
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
|
||||
except HTTPException:
|
||||
raise
|
||||
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
|
||||
raise HTTPException(404, "Worker not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
|
||||
(worker_id, limit)).fetchall()
|
||||
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
return {"workspace_id": wid,
|
||||
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
|
||||
"user_id": user.get("id")}
|
||||
+69
-15
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -25,15 +26,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
|
||||
def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
|
||||
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
|
||||
promouvoir admin."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row or row["role"] != "admin":
|
||||
raise HTTPException(403, "Workspace admin role required")
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
@@ -58,6 +80,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
@@ -68,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
@@ -84,6 +109,7 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -97,6 +123,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
@@ -133,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@@ -153,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@@ -219,7 +246,7 @@ async def add_favorite(request: Request):
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@@ -466,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_sprint")
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -496,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_sprint")
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@@ -664,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
@@ -671,12 +699,20 @@ async def list_webhooks(request: Request):
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
@@ -688,6 +724,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
@@ -698,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
|
||||
sur le titre de la base ou d'une ligne).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
# 404 explicite : le handler global transformerait un HTTPException(404)
|
||||
# en redirection 302 → login pour un chemin HTML.
|
||||
return HTMLResponse(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
|
||||
"<body><h1>404 — Not found</h1></body></html>",
|
||||
status_code=404,
|
||||
)
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
esc = html.escape
|
||||
name = esc(str(coll["name"] or ""))
|
||||
icon = esc(str(coll["icon"] or ""))
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
|
||||
@@ -18,15 +18,30 @@ 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__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
|
||||
async def _fire_agent_webhook(event: str, payload: dict) -> None:
|
||||
"""Dispatch an outbound agent lifecycle event (never raises).
|
||||
|
||||
Lifecycle: ``agent.run.started`` → ``agent.run.finished`` | ``agent.run.failed``.
|
||||
All three are in the webhook_outbound catalogue, so integrations can subscribe
|
||||
to `agent.*` and drive FlowDeck Agent from outside (Agent phase 5).
|
||||
"""
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("%s webhook dispatch failed", event)
|
||||
|
||||
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
@@ -151,6 +166,11 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
await _fire_agent_webhook("agent.run.started", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": model or "",
|
||||
})
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
@@ -166,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
|
||||
@@ -216,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})
|
||||
@@ -260,19 +305,20 @@ class AgentEngine:
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
await _fire_agent_webhook("agent.run.failed", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"error": str(exc)[:500],
|
||||
})
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
@@ -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())
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("row_to_dict")
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
@@ -257,7 +260,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("audit_log")
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
@@ -307,4 +310,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("store_idempotency")
|
||||
|
||||
+423
-2
@@ -23,7 +23,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -167,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
_parsed = _urlparse(url)
|
||||
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
|
||||
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
@@ -246,6 +254,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
)
|
||||
return f"notified user {user_id}"
|
||||
|
||||
if atype == "slack":
|
||||
url = _secret_value(action.get("webhook_url") or action.get("url") or "")
|
||||
if not url:
|
||||
raise ValueError("slack action requires a webhook_url")
|
||||
_, text = _maybe_convert_prediction(
|
||||
action.get("text") or action.get("message") or "Automation fired", context)
|
||||
return await _post_slack(url, text)
|
||||
|
||||
if atype == "email":
|
||||
to = action.get("to", "")
|
||||
_, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context)
|
||||
_, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context)
|
||||
return await _send_email_action(to, subject, body, context)
|
||||
|
||||
if atype == "forge_issue":
|
||||
provider = (action.get("provider") or "gitea").lower()
|
||||
owner = action.get("owner", "")
|
||||
repo = action.get("repo", "")
|
||||
if not owner or not repo:
|
||||
raise ValueError("forge_issue requires owner + repo")
|
||||
_, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context)
|
||||
_, body = _maybe_convert_prediction(action.get("body", ""), context)
|
||||
return await _create_forge_issue(
|
||||
provider, owner, repo, title, body,
|
||||
labels=action.get("labels") or [],
|
||||
user_id=context.get("created_by"),
|
||||
)
|
||||
|
||||
if atype == "agent_trigger":
|
||||
agent_id = action.get("agent_id")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_trigger requires an agent_id")
|
||||
_, message = _maybe_convert_prediction(action.get("message", ""), context)
|
||||
return await _run_linked_agent(
|
||||
int(agent_id), context.get("created_by") or 1,
|
||||
context.get("workspace_id"), message, context)
|
||||
|
||||
raise ValueError(f"unknown action type: {atype!r}")
|
||||
|
||||
|
||||
@@ -260,6 +305,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
if not auto["enabled"]:
|
||||
return {"status": "skipped", "detail": "automation disabled"}
|
||||
|
||||
# v7.0.0: chained steps take over when present (legacy path otherwise).
|
||||
stepped = await _maybe_run_stepped(auto, trigger_source, context)
|
||||
if stepped is not None:
|
||||
return stepped
|
||||
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
if not evaluate_conditions(auto["condition_json"], props, before):
|
||||
@@ -307,6 +357,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||
if event == "page.updated":
|
||||
try:
|
||||
from app.services.wiki import notify_followers_of_page_update
|
||||
notify_followers_of_page_update(
|
||||
payload.get("page_id"), payload.get("actor_id"),
|
||||
payload.get("title") or "")
|
||||
except Exception: # noqa: BLE001 — notifications are best-effort
|
||||
logger.debug("followers notification failed for page.updated")
|
||||
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as fire_webhooks
|
||||
@@ -320,14 +379,29 @@ async def fire_event(event: str, payload: dict):
|
||||
WHERE trigger_type='event' AND event=? AND enabled=1""",
|
||||
(event,),
|
||||
).fetchall()
|
||||
stepped_ids: set[int] = set()
|
||||
try:
|
||||
with get_conn() as _c:
|
||||
stepped_ids = {r[0] for r in _c.execute(
|
||||
"SELECT DISTINCT automation_id FROM automation_steps").fetchall()}
|
||||
except Exception: # noqa: BLE001 — table missing on very old DBs
|
||||
pass
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
if auto["id"] in stepped_ids:
|
||||
continue # v7.0.0: handled by fire_stepped_event below (no double run)
|
||||
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
|
||||
continue
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
# v7.0.0: step-based automations (multi-trigger any/all, chains).
|
||||
try:
|
||||
await fire_stepped_event(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("stepped dispatch failed for %s", event)
|
||||
|
||||
|
||||
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
|
||||
|
||||
@@ -346,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
now = now or datetime.now(UTC).replace(tzinfo=None)
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
@@ -409,6 +483,353 @@ async def automation_scheduler():
|
||||
await run_automation(auto["id"], "cron", context)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Cron automation %s errored", auto["id"])
|
||||
# v7.0.0: workers on a cron schedule share the same 60s loop.
|
||||
try:
|
||||
from app.services.workers import run_due_workers
|
||||
await run_due_workers()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("worker cron iteration failed")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
|
||||
|
||||
STEP_KINDS = ("trigger", "condition", "delay", "action")
|
||||
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
|
||||
"slack", "email", "forge_issue", "agent_trigger")
|
||||
ALL_MODE_WINDOW_S = 300.0
|
||||
|
||||
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
|
||||
_ALL_PENDING: dict[int, dict[str, float]] = {}
|
||||
|
||||
|
||||
def reset_all_pending() -> None:
|
||||
"""Test helper: clear the mode=all arrival window."""
|
||||
_ALL_PENDING.clear()
|
||||
|
||||
|
||||
def _secret_value(stored: str | None) -> str:
|
||||
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
decrypted = decrypt_secret(stored)
|
||||
if decrypted:
|
||||
return decrypted
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
|
||||
return stored
|
||||
return ""
|
||||
|
||||
|
||||
def validate_step(kind: str, config: dict) -> None:
|
||||
"""Validate a step payload. Raises ValueError with a human message."""
|
||||
from fastapi import HTTPException
|
||||
if kind not in STEP_KINDS:
|
||||
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
|
||||
config = config or {}
|
||||
if kind == "trigger":
|
||||
if not config.get("event"):
|
||||
raise HTTPException(400, "trigger step requires an event")
|
||||
elif kind == "condition":
|
||||
if config.get("op", "eq") not in COND_OPS:
|
||||
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
|
||||
elif kind == "delay":
|
||||
try:
|
||||
seconds = int(config.get("seconds", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "delay step requires integer seconds") from None
|
||||
if seconds < 0 or seconds > 86400:
|
||||
raise HTTPException(400, "delay seconds must be 0..86400")
|
||||
elif kind == "action":
|
||||
if config.get("type") not in STEP_ACTION_TYPES:
|
||||
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
|
||||
|
||||
|
||||
def get_steps(automation_id: int) -> list[dict]:
|
||||
"""Ordered steps of an automation (empty when legacy single-mode)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
|
||||
(automation_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["config"] = json.loads(d.get("config_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
d["config"] = {}
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
|
||||
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
|
||||
"delay": [], "action": []}
|
||||
for s in steps:
|
||||
if s.get("kind") in grouped:
|
||||
grouped[s["kind"]].append(s)
|
||||
return grouped
|
||||
|
||||
|
||||
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
|
||||
automation_collection_id: int | None) -> bool:
|
||||
if step_cfg.get("event") != event:
|
||||
return False
|
||||
want_coll = step_cfg.get("collection_id") or automation_collection_id
|
||||
if want_coll and payload.get("collection_id") != want_coll:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
|
||||
context: dict) -> dict:
|
||||
"""Execute a chained automation. Records one run row with per-step detail."""
|
||||
grouped = _steps_by_kind(steps)
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
# Legacy single condition still applies on top of step conditions.
|
||||
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
|
||||
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
for cond in grouped["condition"]:
|
||||
cfg = cond.get("config") or {}
|
||||
if not match_condition_props(props, before, {
|
||||
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
|
||||
"value": cfg.get("value")}):
|
||||
_save_run(auto["id"], trigger_source, "skipped",
|
||||
f"step condition not met: {cfg.get('property')}",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "step condition not met"}
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
|
||||
results = []
|
||||
try:
|
||||
for step in ordered:
|
||||
kind = step.get("kind")
|
||||
cfg = step.get("config") or {}
|
||||
if kind in ("trigger", "condition"):
|
||||
continue
|
||||
if kind == "delay":
|
||||
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
results.append(f"delay {cfg.get('seconds', 0)}s")
|
||||
elif kind == "action":
|
||||
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
|
||||
trigger_source)
|
||||
results.append(summary)
|
||||
detail = "; ".join(results) or "no steps executed"
|
||||
_save_run(auto["id"], trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": auto["id"], "name": auto["name"],
|
||||
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"), "detail": detail})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
|
||||
_save_run(auto["id"], trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
|
||||
"""Run via steps when the automation has any; None → use legacy path."""
|
||||
steps = get_steps(auto["id"])
|
||||
if not steps:
|
||||
return None
|
||||
return await _run_with_steps(auto, steps, trigger_source, context)
|
||||
|
||||
|
||||
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
|
||||
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.* FROM automations a
|
||||
JOIN automation_steps s ON s.automation_id = a.id
|
||||
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
|
||||
).fetchall()
|
||||
matched = []
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
steps = get_steps(auto["id"])
|
||||
triggers = [s for s in steps if s.get("kind") == "trigger"]
|
||||
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
|
||||
auto.get("collection_id")) for t in triggers):
|
||||
matched.append((auto, triggers))
|
||||
return matched
|
||||
|
||||
|
||||
async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
"""Dispatch ``event`` to step-based automations (mode any/all).
|
||||
|
||||
Called from :func:`fire_event` after the legacy matcher. Unknown events
|
||||
(not in the webhook catalogue) still work here — steps are independent
|
||||
from outbound webhooks.
|
||||
"""
|
||||
now = time.time()
|
||||
for auto, triggers in _match_stepped_automations(event, payload):
|
||||
# Skip automations already handled by the legacy matcher to avoid
|
||||
# double runs (legacy = trigger_type event + no steps).
|
||||
if not get_steps(auto["id"]):
|
||||
continue
|
||||
mode = (auto.get("trigger_mode") or "any").lower()
|
||||
if mode == "all":
|
||||
pending = _ALL_PENDING.setdefault(auto["id"], {})
|
||||
pending[event] = now
|
||||
# Expire arrivals outside the window.
|
||||
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
|
||||
del pending[ev]
|
||||
wanted = {t.get("config", {}).get("event") for t in triggers}
|
||||
if not wanted <= set(pending):
|
||||
continue
|
||||
_ALL_PENDING.pop(auto["id"], None)
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
return f"slack → ({resp.status_code})"
|
||||
|
||||
|
||||
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
|
||||
from app.services import mailer
|
||||
address = (to or "").strip()
|
||||
if address.startswith("user:"):
|
||||
try:
|
||||
uid = int(address.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raise ValueError(f"bad email target: {to!r}") from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
|
||||
address = (row["email"] if row and row["email"] else "")
|
||||
if not address:
|
||||
raise ValueError(f"user {uid} has no email")
|
||||
if not address:
|
||||
address = None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?",
|
||||
(context.get("created_by") or 1,)).fetchone()
|
||||
if row and row["email"]:
|
||||
address = row["email"]
|
||||
if not address:
|
||||
return "email skipped (no recipient)"
|
||||
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
|
||||
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
|
||||
|
||||
|
||||
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
body: str, labels: list | None = None,
|
||||
user_id: int | None = None) -> str:
|
||||
token = ""
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
|
||||
(user_id, provider)).fetchone()
|
||||
token = (row["access_token"] if row else "") or ""
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json"},
|
||||
json={"title": title, "body": body,
|
||||
"labels": labels or []} if labels else {"title": title, "body": body},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"github returned HTTP {resp.status_code}")
|
||||
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
|
||||
# gitea (default)
|
||||
from app.services.gitea_client import GiteaClient
|
||||
gitea = GiteaClient(user_token=token or None)
|
||||
issue = await gitea.create_issue(owner, repo, title, body)
|
||||
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
|
||||
|
||||
|
||||
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
|
||||
message: str, context: dict) -> str:
|
||||
from app.services.agent_engine import AgentEngine
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise ValueError(f"agent {agent_id} not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user_id,
|
||||
f"Automation: {context.get('automation_name', 'run')}",
|
||||
json.dumps({"workspace_id": workspace_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
objective = ((agent["system_instructions"] or "").strip()
|
||||
or f"Exécute l'agent « {agent['name']} ».")
|
||||
if message:
|
||||
objective = f"{objective}\n\n{message}"
|
||||
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
|
||||
final = ""
|
||||
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
|
||||
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||
final = (row["content"][:300] if row and row["content"] else "")
|
||||
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
|
||||
|
||||
|
||||
# ── v7.0.0 native DB button ────────────────────────────────────────────────
|
||||
|
||||
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
|
||||
user_id: int) -> dict:
|
||||
"""Run the automation linked to a ``button`` property cell."""
|
||||
with get_conn() as conn:
|
||||
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(int(prop_ref), collection_id)).fetchone()
|
||||
else:
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, prop_ref)).fetchone()
|
||||
if not prop:
|
||||
raise ValueError("button property not found")
|
||||
prop = dict(prop)
|
||||
if prop.get("prop_type") != "button":
|
||||
raise ValueError("property is not a button")
|
||||
auto_id = prop.get("button_automation_id")
|
||||
if not auto_id:
|
||||
raise ValueError("button has no linked automation")
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("row not found")
|
||||
context = get_page_context(row_id, collection_id)
|
||||
context["created_by"] = user_id
|
||||
return await run_automation(auto_id, "button", context)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("backup_db")
|
||||
|
||||
dest_dir = _backup_dir()
|
||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
"""FlowDeck — external calendar sync (v7.1.0).
|
||||
|
||||
Bidirectional sync between a collection (date property) and an external
|
||||
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
|
||||
extra dependency). Tokens are Fernet-encrypted at rest.
|
||||
|
||||
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
|
||||
Conflicts (both sides changed since ``last_sync``): last-write-wins +
|
||||
in-app ``calendar.conflict`` notification (manual edit resolves).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS = ("google", "caldav")
|
||||
SYNC_LOOKBACK_DAYS = 30
|
||||
SYNC_LOOKAHEAD_DAYS = 90
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
"""Raised when the remote calendar cannot be reached/authorized."""
|
||||
|
||||
|
||||
# ── links ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encrypt_tokens(creds: dict) -> str:
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
return encrypt_secret(json.dumps(creds or {}))
|
||||
|
||||
|
||||
def _decrypt_tokens(tokens_enc: str) -> dict:
|
||||
if not tokens_enc:
|
||||
return {}
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
raw = decrypt_secret(tokens_enc)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try: # legacy plaintext (tests)
|
||||
data = json.loads(tokens_enc)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def save_link(user_id: int, provider: str, collection_id: int,
|
||||
credentials: dict, calendar_id: str = "primary",
|
||||
date_property: str = "") -> dict:
|
||||
if provider not in PROVIDERS:
|
||||
raise ValueError(f"provider must be google|caldav, got {provider!r}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?",
|
||||
(collection_id,)).fetchone():
|
||||
raise ValueError("collection not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO calendar_links
|
||||
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
|
||||
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
|
||||
date_property=excluded.date_property""",
|
||||
(user_id, provider, _encrypt_tokens(credentials),
|
||||
calendar_id or "primary", collection_id, date_property or ""))
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
|
||||
(user_id, provider, calendar_id or "primary")).fetchone()
|
||||
_ = cur
|
||||
out = dict(row)
|
||||
out.pop("tokens_enc", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_links(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, provider, calendar_id, collection_id,"
|
||||
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
|
||||
" ORDER BY id", (user_id,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_link(user_id: int, link_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
|
||||
(link_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def _load_link(link_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
|
||||
|
||||
def _remote_event(eid: str, title: str, start: str, description: str = "",
|
||||
updated: str = "") -> dict:
|
||||
return {"id": str(eid), "title": title or "Untitled", "start": start,
|
||||
"description": description or "", "updated": updated or ""}
|
||||
|
||||
|
||||
async def google_list_events(tokens: dict, calendar_id: str,
|
||||
time_min: str, time_max: str) -> list[dict]:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
out = []
|
||||
for item in resp.json().get("items", []):
|
||||
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
|
||||
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
|
||||
start, item.get("description", ""),
|
||||
item.get("updated", "")))
|
||||
return out
|
||||
|
||||
|
||||
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
remote_id: str = "") -> str:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
body = {"summary": event.get("title", ""),
|
||||
"description": event.get("description", ""),
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
else:
|
||||
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
|
||||
json=body)
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
return str(resp.json().get("id", remote_id or ""))
|
||||
|
||||
|
||||
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop><D:getetag/><C:calendar-data/></D:prop>
|
||||
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{start}" end="{end}"/>
|
||||
</C:comp-filter></C:comp-filter></C:filter>
|
||||
</C:calendar-query>"""
|
||||
|
||||
|
||||
def _parse_caldav_events(xml_text: str) -> list[dict]:
|
||||
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
events = []
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
|
||||
for resp in root.findall("D:response", ns):
|
||||
href = resp.findtext("D:href", default="", namespaces=ns)
|
||||
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
|
||||
if data_el is None or not data_el.text:
|
||||
continue
|
||||
ics = data_el.text
|
||||
uid = re.search(r"^UID:(.+)$", ics, re.M)
|
||||
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
|
||||
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
|
||||
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
|
||||
events.append(_remote_event(
|
||||
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
|
||||
summary.group(1).strip() if summary else "Untitled",
|
||||
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
|
||||
desc.group(1).strip() if desc else ""))
|
||||
return events
|
||||
|
||||
|
||||
def _ics_to_date(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 8 and value[:8].isdigit():
|
||||
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
|
||||
return value[:10]
|
||||
|
||||
|
||||
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
day = (date or "")[:10].replace("-", "")
|
||||
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
|
||||
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
|
||||
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
|
||||
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
|
||||
url = creds.get("url", "")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("caldav rejected credentials")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return _parse_caldav_events(resp.text)
|
||||
|
||||
|
||||
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
|
||||
url = (creds.get("url", "") or "").rstrip("/")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
|
||||
href = f"{url}/{uid}.ics" if not remote_id else (
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return uid
|
||||
|
||||
|
||||
# ── mapping + sync ─────────────────────────────────────────────────────────
|
||||
|
||||
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
|
||||
props = conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()
|
||||
if wanted:
|
||||
for p in props:
|
||||
if str(p["id"]) == str(wanted) or p["name"] == wanted:
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
for p in props:
|
||||
# prop_type lives in the row; fetch full rows only when needed
|
||||
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
|
||||
(p["id"],)).fetchone()
|
||||
if full and full["prop_type"] == "date":
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
raw = values.get(prop_id, values.get(prop_name, ""))
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("date") or raw.get("value") or ""
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
text = str(value).strip()
|
||||
try:
|
||||
if text.endswith("Z"):
|
||||
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp()
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def _window() -> tuple[str, str]:
|
||||
now = datetime.now(UTC)
|
||||
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
return start, end
|
||||
|
||||
|
||||
async def sync_link(link_id: int) -> dict:
|
||||
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
|
||||
link = _load_link(link_id)
|
||||
if not link:
|
||||
raise ValueError("link not found")
|
||||
creds = _decrypt_tokens(link.get("tokens_enc") or "")
|
||||
collection_id = link.get("collection_id")
|
||||
if not collection_id:
|
||||
raise ValueError("link has no collection")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
|
||||
tmin, tmax = _window()
|
||||
if link["provider"] == "google":
|
||||
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
|
||||
tmin, tmax)
|
||||
else:
|
||||
remote = await caldav_list_events(creds, tmin, tmax)
|
||||
|
||||
last_sync = _to_epoch(link.get("last_sync"))
|
||||
pulled = pushed = conflicts = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at,"
|
||||
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
|
||||
seen_remote: set[str] = set()
|
||||
touched: set[int] = set() # rows written by this pull pass — never push back
|
||||
|
||||
for ev in remote:
|
||||
eid = ev.get("id", "")
|
||||
if not eid:
|
||||
continue
|
||||
seen_remote.add(eid)
|
||||
day = (ev.get("start") or "")[:10]
|
||||
if eid not in local:
|
||||
values: dict = {}
|
||||
values[prop_id] = day
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, position, property_values_json, external_event_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(collection_id, ev.get("title") or "Untitled", max_pos,
|
||||
json.dumps(values), eid))
|
||||
pulled += 1
|
||||
continue
|
||||
row = local[eid]
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
local_day = _row_date(values, prop_id, prop_name)[:10]
|
||||
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
|
||||
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
|
||||
if remote_newer and local_dirty and local_day and day and local_day != day:
|
||||
# Conflict: both sides moved → last-write-wins + notify.
|
||||
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
conflicts += 1
|
||||
_notify_conflict(conn, link, row, ev)
|
||||
elif day and day != local_day:
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
pulled += 1
|
||||
|
||||
# Push local changes (created locally or edited after last_sync).
|
||||
for xid, row in local.items():
|
||||
if row["id"] in touched:
|
||||
continue
|
||||
if xid in seen_remote:
|
||||
# Edited locally since last sync and remote unchanged → push.
|
||||
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
|
||||
await _push(link, creds, row, prop_id, prop_name, xid)
|
||||
pushed += 1
|
||||
continue
|
||||
# Remote deleted the event → drop the local id (keep the row).
|
||||
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
|
||||
(row["id"],))
|
||||
# Rows never linked and recently touched → create remotely.
|
||||
fresh = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
|
||||
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
|
||||
(collection_id,)).fetchall()
|
||||
for row in fresh:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
day = _row_date(values, prop_id, prop_name)[:10]
|
||||
if not day:
|
||||
continue
|
||||
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
|
||||
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
|
||||
(new_id, row["id"]))
|
||||
pushed += 1
|
||||
|
||||
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(link_id,))
|
||||
conn.commit()
|
||||
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
|
||||
|
||||
|
||||
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
|
||||
prop_name: str, remote_id: str) -> str:
|
||||
try:
|
||||
values = json.loads(row.get("property_values_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
event = {"title": row.get("title") or "Untitled",
|
||||
"start": _row_date(values, prop_id, prop_name),
|
||||
"description": ""}
|
||||
if link["provider"] == "google":
|
||||
return await google_push_event(creds, link.get("calendar_id") or "primary",
|
||||
event, remote_id)
|
||||
return await caldav_push_event(creds, event, remote_id)
|
||||
|
||||
|
||||
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
create_notification(
|
||||
link["user_id"], link["user_id"], "calendar",
|
||||
"Calendar sync conflict",
|
||||
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
|
||||
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
|
||||
resource_type="collection", resource_id=link.get("collection_id") or 0,
|
||||
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
|
||||
except Exception: # noqa: BLE001 — notify must never break sync
|
||||
pass
|
||||
|
||||
|
||||
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
||||
"""Background loop: sync every link with a collection (15 min default)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
|
||||
).fetchall()]
|
||||
for link_id in ids:
|
||||
try:
|
||||
await sync_link(link_id)
|
||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("calendar_sync_scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
def freebusy(collection_id: int, date_from: str, date_to: str,
|
||||
date_property: str = "") -> dict:
|
||||
"""Busy/free weekdays in [date_from, date_to] (day granularity).
|
||||
|
||||
Expands recurrence rules server-side (``recurrence.expand_rule``).
|
||||
"""
|
||||
from app.services import recurrence as _rec
|
||||
try:
|
||||
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
|
||||
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError("use YYYY-MM-DD dates") from None
|
||||
if end < start or (end - start).days > 370:
|
||||
raise ValueError("range must be 1..370 days")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, date_property)
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,)).fetchall()
|
||||
busy: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
values = json.loads(r["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
base = _row_date(values, prop_id, prop_name)
|
||||
if not base:
|
||||
continue
|
||||
rec = (values.get("__recurrence__") or {})
|
||||
rule = rec.get(prop_id) or rec.get(prop_name)
|
||||
if rule:
|
||||
try:
|
||||
for occ in _rec.expand_rule(
|
||||
base, rule, start.isoformat(), end.isoformat()):
|
||||
busy.add(occ[:10])
|
||||
except Exception: # noqa: BLE001 — bad rule, use base date only
|
||||
busy.add(base[:10])
|
||||
else:
|
||||
if start.isoformat() <= base[:10] <= end.isoformat():
|
||||
busy.add(base[:10])
|
||||
days, free = [], []
|
||||
day = start
|
||||
while day <= end:
|
||||
iso = day.isoformat()
|
||||
days.append({"date": iso, "busy": iso in busy,
|
||||
"weekend": day.weekday() >= 5})
|
||||
if iso not in busy and day.weekday() < 5:
|
||||
free.append(iso)
|
||||
day += timedelta(days=1)
|
||||
return {"collection_id": collection_id, "from": start.isoformat(),
|
||||
"to": end.isoformat(), "days": days, "free_weekdays": free}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
|
||||
if langs:
|
||||
repo_info["language"] = max(langs, key=langs.get)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("get_repo_info")
|
||||
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
@@ -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"))}
|
||||
@@ -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
|
||||
|
||||
@@ -98,6 +98,74 @@ class PermissionManager:
|
||||
).fetchone()
|
||||
return "owner" if owner else "viewer"
|
||||
|
||||
# ── SSO (v6.7.0, design §7.2) ─────────────────────────────────────────
|
||||
|
||||
def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool:
|
||||
"""True when that workspace can only be reached through SSO.
|
||||
|
||||
FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 /
|
||||
§4.2): when it is on, local login is refused for every non-admin, so
|
||||
every workspace on the instance is effectively SSO-only.
|
||||
``workspace_id`` is accepted to mirror the design's per-workspace API.
|
||||
"""
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
|
||||
return is_sso_only()
|
||||
|
||||
def _user_auth_method(self) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT auth_method FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return (row["auth_method"] or "local") if row else "local"
|
||||
|
||||
def get_sso_roles(
|
||||
self, user_id: int | None = None, workspace_id: int | None = None
|
||||
) -> list[str]:
|
||||
"""Roles granted to that user through SSO group mapping (design §7.2).
|
||||
|
||||
SSO grants land in the regular ``workspace_members`` row (the mapping
|
||||
is re-applied at every SSO login), so the answer is the explicit
|
||||
membership role of a non-local account — local accounts and users
|
||||
without an explicit grant (the implicit *viewer* fallback is not an
|
||||
SSO grant) get ``[]``.
|
||||
"""
|
||||
if workspace_id is None:
|
||||
return []
|
||||
pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self
|
||||
if pm._user_auth_method() == "local":
|
||||
return []
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(int(workspace_id), pm.user_id),
|
||||
).fetchone()
|
||||
return [row["role"]] if row else []
|
||||
|
||||
def sync_sso_permissions(
|
||||
self,
|
||||
user_id: int | None,
|
||||
sso_groups: list[str],
|
||||
workspace_id: int | None = None,
|
||||
) -> list[int]:
|
||||
"""Re-apply the group → workspace role mapping (design §7.2).
|
||||
|
||||
Delegates to ``sso_provisioning.sync_sso_groups`` (the single source
|
||||
of truth used at login and by ``POST /api/v2/sso/sync``). Returns the
|
||||
touched workspace ids, narrowed to ``workspace_id`` when given.
|
||||
"""
|
||||
from app.services.sso_provisioning import get_sso_config, sync_sso_groups
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
return []
|
||||
touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg)
|
||||
if workspace_id is not None:
|
||||
touched = [w for w in touched if int(w) == int(workspace_id)]
|
||||
if touched:
|
||||
self.invalidate()
|
||||
return touched
|
||||
|
||||
def can_read(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in READ_ROLES
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -349,7 +349,7 @@ class RealtimeManager:
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_evict_slow")
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »).
|
||||
|
||||
Single source of truth for shareable agent skills:
|
||||
|
||||
* **Gallery** — built-in presets shipped with FlowDeck, installable into any
|
||||
workspace with one call (the self-hosted equivalent of a skill marketplace).
|
||||
* **Portable payloads** — a skill exports to a versioned JSON document that any
|
||||
other FlowDeck instance can re-import unchanged.
|
||||
|
||||
Both the internal router (``/api/agent/skills/*``, session cookie) and the
|
||||
public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so
|
||||
there is exactly one implementation of export/import/install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
EXPORT_FORMAT = "flowdeck-skill"
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
# ── Gallery presets ────────────────────────────────────────────────────────
|
||||
# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set),
|
||||
# so an installed preset can never expose a tool that does not exist.
|
||||
|
||||
GALLERY: dict[str, dict] = {
|
||||
"rapport-hebdo": {
|
||||
"name": "Rapport hebdo",
|
||||
"icon": "📊",
|
||||
"description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.",
|
||||
"prompt_template": (
|
||||
"Rédige le rapport hebdomadaire de l'équipe.\n"
|
||||
"1. Repère les pages et documents modifiés cette semaine (search_workspace).\n"
|
||||
"2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n"
|
||||
"3. Crée un document « Rapport hebdo — <date> » (create_document) structuré ainsi : "
|
||||
"Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n"
|
||||
"Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"compte-rendu-reunion": {
|
||||
"name": "Compte rendu de réunion",
|
||||
"icon": "📝",
|
||||
"description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.",
|
||||
"prompt_template": (
|
||||
"À partir de la note de réunion fournie (ou demandée) :\n"
|
||||
"1. Crée un document « CR — <titre> » (create_document).\n"
|
||||
"2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n"
|
||||
"3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"base-crm": {
|
||||
"name": "Base CRM",
|
||||
"icon": "🗂️",
|
||||
"description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.",
|
||||
"prompt_template": (
|
||||
"Crée une base CRM complète :\n"
|
||||
"1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), "
|
||||
"Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n"
|
||||
"2. Vue board groupée sur Statut (create_view).\n"
|
||||
"3. Trois lignes d'exemple réalistes (create_page).\n"
|
||||
"Termine par le lien/identifiant de la collection créée."
|
||||
),
|
||||
"allowed_tools": ["create_collection", "add_property", "create_view", "create_page"],
|
||||
},
|
||||
"okr": {
|
||||
"name": "Objectifs OKR",
|
||||
"icon": "🎯",
|
||||
"description": "Génère une base d'OKR avec objectifs, résultats clés et progression.",
|
||||
"prompt_template": (
|
||||
"Crée une base « OKR » :\n"
|
||||
"1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), "
|
||||
"Progression (nombre 0-100).\n"
|
||||
"2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n"
|
||||
"3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n"
|
||||
"4. Vue board par période (create_view)."
|
||||
),
|
||||
"allowed_tools": [
|
||||
"create_collection", "add_property", "add_relation",
|
||||
"create_view", "create_page", "create_sub_item",
|
||||
],
|
||||
},
|
||||
"analyse-repo": {
|
||||
"name": "Analyse repo Gitea",
|
||||
"icon": "🛠️",
|
||||
"description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.",
|
||||
"prompt_template": (
|
||||
"Analyse le dépôt Gitea du workspace :\n"
|
||||
"1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n"
|
||||
"2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n"
|
||||
"3. Crée un document « Suivi repo — <dépôt> » (create_document) avec un tableau des issues "
|
||||
"et 3 recommandations de priorisation."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"],
|
||||
},
|
||||
"resume-document": {
|
||||
"name": "Résumé de document",
|
||||
"icon": "📄",
|
||||
"description": "Résume un document long en une page : points clés, chiffres, décisions.",
|
||||
"prompt_template": (
|
||||
"Résume le document fourni (ou demandé) :\n"
|
||||
"1. Lis-le intégralement (read_document).\n"
|
||||
"2. Crée un document « Résumé — <titre> » (create_document) : 5 points clés, chiffres marquants, "
|
||||
"décisions/engagements, questions restées ouvertes.\n"
|
||||
"3. Maximum une page, phrases courtes, aucune reformulation qui change le sens."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Gallery access ─────────────────────────────────────────────────────────
|
||||
|
||||
def list_gallery() -> list[dict]:
|
||||
"""Return every preset with its slug, ready for API responses."""
|
||||
return [{"slug": slug, **preset} for slug, preset in GALLERY.items()]
|
||||
|
||||
|
||||
def get_gallery(slug: str) -> dict | None:
|
||||
preset = GALLERY.get(str(slug or "").strip().lower())
|
||||
return {"slug": slug, **preset} if preset else None
|
||||
|
||||
|
||||
# ── Portable payload (export / import) ─────────────────────────────────────
|
||||
|
||||
def export_skill(row: Any) -> dict:
|
||||
"""Build the portable JSON document for a stored skill row.
|
||||
|
||||
Only shareable fields are included: ids, workspace and author stay local so
|
||||
an import never leaks (nor depends on) the source instance's internals.
|
||||
"""
|
||||
skill = row if isinstance(row, dict) else dict(row)
|
||||
tools = skill.get("allowed_tools_json") or "[]"
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError):
|
||||
tools = []
|
||||
return {
|
||||
"format": EXPORT_FORMAT,
|
||||
"version": EXPORT_VERSION,
|
||||
"skill": {
|
||||
"name": skill.get("name") or "",
|
||||
"description": skill.get("description") or "",
|
||||
"prompt_template": skill.get("prompt_template") or "",
|
||||
"allowed_tools": list(tools) if isinstance(tools, list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_payload(payload: Any) -> dict:
|
||||
"""Validate an import payload → normalized skill fields.
|
||||
|
||||
Accepts a full exported document (``{format, version, skill}``) or a bare
|
||||
skill object (``{name, prompt_template, ...}``) for hand-written imports.
|
||||
Raises ``ValueError`` with a human-readable message on invalid input.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Payload JSON attendu (objet)")
|
||||
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
|
||||
if payload.get("format") and payload.get("format") != EXPORT_FORMAT:
|
||||
raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})")
|
||||
version = payload.get("version")
|
||||
if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION):
|
||||
raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})")
|
||||
|
||||
name = str(skill.get("name") or "").strip()
|
||||
prompt = str(skill.get("prompt_template") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if not prompt:
|
||||
raise ValueError("prompt_template est requis")
|
||||
tools = skill.get("allowed_tools") or []
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc
|
||||
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils")
|
||||
|
||||
return {
|
||||
"name": name[:120],
|
||||
"description": str(skill.get("description") or "")[:500],
|
||||
"prompt_template": prompt,
|
||||
"allowed_tools": tools,
|
||||
}
|
||||
|
||||
|
||||
def upsert_skill(
|
||||
fields: dict,
|
||||
*,
|
||||
workspace_id: int | None,
|
||||
created_by: int | None,
|
||||
overwrite: bool = False,
|
||||
) -> tuple[dict, bool]:
|
||||
"""Insert (or replace) a skill in the current workspace.
|
||||
|
||||
Returns ``(row, created)``. Raises ``ValueError`` when the name already
|
||||
exists and ``overwrite`` is False — callers translate that to HTTP 409.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, fields["name"]),
|
||||
).fetchone()
|
||||
if existing and not overwrite:
|
||||
raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}")
|
||||
tools_json = json.dumps(fields["allowed_tools"])
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?",
|
||||
(fields["description"], fields["prompt_template"], tools_json, existing["id"]),
|
||||
)
|
||||
skill_id = existing["id"]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_skills
|
||||
(workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(workspace_id, fields["name"], fields["description"],
|
||||
fields["prompt_template"], tools_json, created_by),
|
||||
)
|
||||
skill_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
data = dict(row) if row else {"id": skill_id}
|
||||
return data, existing is None
|
||||
@@ -0,0 +1,783 @@
|
||||
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
|
||||
|
||||
Single source of truth for the SSO configuration (``sso_config`` table, with
|
||||
an ``SSO_*`` environment fallback for bootstrap installs) and for what
|
||||
happens when an IdP says "this is [email protected]":
|
||||
|
||||
1. resolve the local account (by email → merge, else by login),
|
||||
2. create it when ``auto_provision`` is on, else reject with an audit row,
|
||||
3. sync attributes + map SSO groups to workspace roles,
|
||||
4. hand back the user dict so the caller can mint a session.
|
||||
|
||||
Secrets at rest: ``client_secret`` and the generated SP private key are
|
||||
encrypted with a Fernet key derived from ``app_secret_key``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROVIDER_TYPES = ("saml", "oidc")
|
||||
|
||||
|
||||
class SSOConfigError(Exception):
|
||||
"""Invalid SSO configuration payload (message shown to the admin)."""
|
||||
|
||||
|
||||
class SSOProvisioningError(Exception):
|
||||
"""A login was rejected (no local account, missing attributes…)."""
|
||||
|
||||
|
||||
# ── Secrets at rest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fernet():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
||||
import base64
|
||||
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(value.encode()).decode()
|
||||
except Exception:
|
||||
return "" # key rotated / not ours — treat as unset
|
||||
|
||||
|
||||
# ── Config store ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _default_mapping(provider_type: str) -> dict:
|
||||
if provider_type == "oidc":
|
||||
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
|
||||
|
||||
return dict(DEFAULT_OIDC_MAPPING)
|
||||
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
|
||||
|
||||
return dict(DEFAULT_SAML_MAPPING)
|
||||
|
||||
|
||||
def _env_config() -> dict | None:
|
||||
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
|
||||
|
||||
Only used when the table holds no active row — the Settings UI always
|
||||
wins once an admin saved a configuration.
|
||||
"""
|
||||
provider_type = (settings.sso_provider or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
return None
|
||||
cfg = {
|
||||
"id": 0,
|
||||
"provider_type": provider_type,
|
||||
"name": settings.sso_name or "Company SSO",
|
||||
"entity_id": settings.sso_entity_id,
|
||||
"sso_url": settings.sso_sso_url,
|
||||
"slo_url": settings.sso_slo_url,
|
||||
"x509_certificate": settings.sso_x509_certificate,
|
||||
"issuer_url": settings.sso_issuer_url,
|
||||
"client_id": settings.sso_client_id,
|
||||
"client_secret": settings.sso_client_secret,
|
||||
"scope": settings.sso_scope,
|
||||
"attribute_mapping": settings.sso_attribute_mapping,
|
||||
"groups_mapping": settings.sso_groups_mapping,
|
||||
"auto_provision": int(settings.sso_auto_provision),
|
||||
"sso_only": int(settings.sso_only),
|
||||
"sign_requests": int(settings.sso_sign_requests),
|
||||
"default_workspace_id": settings.sso_default_workspace_id,
|
||||
"sp_private_key": "",
|
||||
"sp_certificate": "",
|
||||
"workspace_id": None,
|
||||
"active": 1,
|
||||
"_source": "env",
|
||||
}
|
||||
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
|
||||
return None
|
||||
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def get_sso_config(require_active: bool = True) -> dict | None:
|
||||
"""Active SSO config as a dict (DB row, else env fallback)."""
|
||||
row = _raw_row(require_active=require_active)
|
||||
if row:
|
||||
cfg = dict(row)
|
||||
cfg["_source"] = "db"
|
||||
return cfg
|
||||
if not require_active:
|
||||
return _env_config()
|
||||
return _env_config()
|
||||
|
||||
|
||||
def _raw_row(require_active: bool = True) -> dict | None:
|
||||
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
where = "WHERE active=1" if require_active else ""
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception: # table missing (very old install) → env only
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def client_secret_value(cfg: dict) -> str:
|
||||
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
|
||||
raw = (cfg or {}).get("client_secret") or ""
|
||||
if not raw:
|
||||
return ""
|
||||
if (cfg or {}).get("_source") == "env":
|
||||
return raw
|
||||
return decrypt_secret(raw)
|
||||
|
||||
|
||||
def _json_field(value, fallback):
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value or "")
|
||||
return parsed if isinstance(parsed, type(fallback)) else fallback
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _strict_json(value, expected, field: str):
|
||||
"""Parse a payload field and reject wrong shapes (before normalize,
|
||||
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
|
||||
if value is None or value == "":
|
||||
return expected()
|
||||
if isinstance(value, (dict, list)):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise SSOConfigError(f"{field} must be valid JSON") from exc
|
||||
if not isinstance(parsed, expected):
|
||||
kind = "object" if expected is dict else "array"
|
||||
raise SSOConfigError(f"{field} must be a JSON {kind}")
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_config(cfg: dict) -> dict:
|
||||
"""Parse JSON columns + fill defaults (single place for every consumer)."""
|
||||
out = dict(cfg)
|
||||
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
|
||||
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
|
||||
if not out["attribute_mapping"]:
|
||||
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
|
||||
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
|
||||
"client_id", "client_secret", "scope", "name"):
|
||||
out[key] = (out.get(key) or "").strip()
|
||||
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
|
||||
out[key] = int(out.get(key) or 0)
|
||||
return out
|
||||
|
||||
|
||||
def validate_config_payload(payload: dict) -> dict:
|
||||
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
|
||||
provider_type = str(payload.get("provider_type") or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
|
||||
|
||||
payload = dict(payload)
|
||||
payload["attribute_mapping"] = _strict_json(
|
||||
payload.get("attribute_mapping"), dict, "attribute_mapping"
|
||||
)
|
||||
payload["groups_mapping"] = _strict_json(
|
||||
payload.get("groups_mapping"), list, "groups_mapping"
|
||||
)
|
||||
cfg = normalize_config({**payload, "provider_type": provider_type})
|
||||
|
||||
if provider_type == "saml":
|
||||
for field in ("entity_id", "sso_url"):
|
||||
if not cfg[field]:
|
||||
raise SSOConfigError(f"SAML requires '{field}'")
|
||||
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise SSOConfigError(f"'{field}' must be an http(s) URL")
|
||||
cert = cfg["x509_certificate"].strip()
|
||||
if cert and "BEGIN CERTIFICATE" not in cert:
|
||||
raise SSOConfigError("x509_certificate must be a PEM certificate")
|
||||
if not cert:
|
||||
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
|
||||
cfg["x509_certificate"] = cert
|
||||
else:
|
||||
if not cfg["issuer_url"] or not cfg["client_id"]:
|
||||
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
|
||||
if not cfg["issuer_url"].startswith(("http://", "https://")):
|
||||
raise SSOConfigError("'issuer_url' must be an http(s) URL")
|
||||
|
||||
if not isinstance(cfg["attribute_mapping"], dict):
|
||||
raise SSOConfigError("attribute_mapping must be a JSON object")
|
||||
if not isinstance(cfg["groups_mapping"], list):
|
||||
raise SSOConfigError("groups_mapping must be a JSON array")
|
||||
for entry in cfg["groups_mapping"]:
|
||||
if not isinstance(entry, dict) or "sso_group" not in entry:
|
||||
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
|
||||
|
||||
ws = cfg.get("default_workspace_id")
|
||||
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
|
||||
return cfg
|
||||
|
||||
|
||||
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
|
||||
"""Create or replace the single SSO configuration (idempotent)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = validate_config_payload(payload)
|
||||
columns = {
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
|
||||
"groups_mapping": json.dumps(cfg["groups_mapping"]),
|
||||
"auto_provision": cfg["auto_provision"],
|
||||
"sso_only": cfg["sso_only"],
|
||||
"sign_requests": cfg["sign_requests"],
|
||||
"default_workspace_id": cfg["default_workspace_id"],
|
||||
"active": 1,
|
||||
"updated_at": str(int(time.time())),
|
||||
}
|
||||
|
||||
# Secret handling: a blank incoming secret keeps the stored one (the raw
|
||||
# row still holds the Fernet blob — never re-encrypt a decrypted value).
|
||||
existing = _raw_row() or {}
|
||||
if "client_secret" in cfg:
|
||||
incoming = str(cfg.get("client_secret") or "").strip()
|
||||
if incoming:
|
||||
columns["client_secret"] = encrypt_secret(incoming)
|
||||
else:
|
||||
columns["client_secret"] = existing.get("client_secret") or ""
|
||||
# SP keypair: keep an existing one, generate one for SAML if missing.
|
||||
sp_key = existing.get("sp_private_key") or ""
|
||||
sp_cert = existing.get("sp_certificate") or ""
|
||||
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
|
||||
sp_key, sp_cert = generate_sp_keypair()
|
||||
columns["sp_private_key"] = sp_key
|
||||
columns["sp_certificate"] = sp_cert
|
||||
if created_by:
|
||||
columns["created_by"] = created_by
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
sets = ", ".join(f"{k}=?" for k in columns)
|
||||
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
|
||||
cfg_id = row["id"]
|
||||
else:
|
||||
keys = ", ".join(columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
|
||||
)
|
||||
cfg_id = cur.lastrowid
|
||||
conn.commit()
|
||||
saved = get_sso_config(require_active=False)
|
||||
saved["id"] = cfg_id
|
||||
return saved
|
||||
|
||||
|
||||
def delete_sso_config() -> bool:
|
||||
"""Disable SSO entirely (local logins keep working)."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def public_config_view(cfg: dict | None) -> dict:
|
||||
"""Config for the admin UI — secrets never leave the server."""
|
||||
if not cfg:
|
||||
return {"configured": False}
|
||||
cfg = normalize_config(cfg)
|
||||
return {
|
||||
"configured": True,
|
||||
"id": cfg.get("id"),
|
||||
"source": cfg.get("_source", "db"),
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"client_secret_set": bool(client_secret_value(cfg)),
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": cfg["attribute_mapping"],
|
||||
"groups_mapping": cfg["groups_mapping"],
|
||||
"auto_provision": bool(cfg["auto_provision"]),
|
||||
"sso_only": bool(cfg["sso_only"]),
|
||||
"sign_requests": bool(cfg["sign_requests"]),
|
||||
"default_workspace_id": cfg.get("default_workspace_id"),
|
||||
"sp_certificate": cfg.get("sp_certificate") or "",
|
||||
"active": bool(cfg.get("active", 1)),
|
||||
"provisioned_users": provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
def is_sso_only(cfg: dict | None = None) -> bool:
|
||||
"""True when local login must be refused (design §7.1 / §4.2)."""
|
||||
cfg = cfg if cfg is not None else get_sso_config()
|
||||
return bool(cfg and normalize_config(cfg).get("sso_only"))
|
||||
|
||||
|
||||
def provisioned_count() -> int:
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchone()
|
||||
return int(row["n"] if row is not None else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def generate_sp_keypair() -> tuple[str, str]:
|
||||
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
|
||||
import datetime
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
|
||||
])
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
priv = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return priv, public
|
||||
|
||||
|
||||
def ensure_sp_keypair(cfg: dict) -> dict:
|
||||
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
|
||||
if cfg.get("provider_type") != "saml":
|
||||
return cfg
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
return cfg
|
||||
from app.db import get_conn
|
||||
|
||||
priv, cert = generate_sp_keypair()
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
|
||||
(priv, cert, cfg.get("id")),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # env-sourced config has no row to update
|
||||
logger.debug("SP keypair not persisted: %s", err)
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
|
||||
|
||||
# ── Audit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def log_sso_login(
|
||||
*,
|
||||
user_id: int | None,
|
||||
provider_type: str,
|
||||
provider_name: str,
|
||||
identifier: str,
|
||||
request,
|
||||
success: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
|
||||
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO sso_login_history
|
||||
(user_id, provider_type, provider_name, sso_identifier,
|
||||
ip_address, user_agent, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
|
||||
1 if success else 0, (error or "")[:500]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # audit must never break the login path
|
||||
logger.warning("sso_login_history write failed: %s", err)
|
||||
|
||||
|
||||
# ── Group mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
|
||||
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
mappings = cfg.get("groups_mapping") or []
|
||||
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
|
||||
touched: list[int] = []
|
||||
|
||||
with get_conn() as conn:
|
||||
for entry in mappings:
|
||||
group_name = str(entry.get("sso_group") or "").strip().lower()
|
||||
if not group_name or group_name not in wanted:
|
||||
continue
|
||||
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
|
||||
if not ws_id:
|
||||
continue
|
||||
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
role = "editor"
|
||||
conn.execute(
|
||||
"""INSERT INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(int(ws_id), user_id, role),
|
||||
)
|
||||
touched.append(int(ws_id))
|
||||
|
||||
# Default workspace: every SSO user lands there as a plain member.
|
||||
default_ws = cfg.get("default_workspace_id")
|
||||
if default_ws:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, 'editor')""",
|
||||
(int(default_ws), user_id),
|
||||
)
|
||||
if int(default_ws) not in touched:
|
||||
touched.append(int(default_ws))
|
||||
conn.commit()
|
||||
return touched
|
||||
|
||||
|
||||
def force_sync_all_groups() -> dict:
|
||||
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
raise SSOProvisioningError("No SSO configuration")
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
groups_source = mapping.get("groups", "groups")
|
||||
updated = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
groups = _stored_groups(row["id"], groups_source, cfg)
|
||||
if sync_sso_groups(row["id"], groups, cfg):
|
||||
updated += 1
|
||||
return {"users": len(rows), "updated": updated}
|
||||
|
||||
|
||||
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
|
||||
"""Groups seen at the last login of that user (stored in attribute sync)."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sso_identifier FROM sso_login_history "
|
||||
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row or not row["sso_identifier"]:
|
||||
return []
|
||||
raw = row["sso_identifier"]
|
||||
if "|" in raw:
|
||||
ident, _, groups_json = raw.partition("|")
|
||||
groups = json.loads(groups_json or "[]")
|
||||
return [str(g) for g in groups] if isinstance(groups, list) else []
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Auto-provisioning ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _unique_login(conn, base: str) -> str:
|
||||
candidate = base
|
||||
n = 1
|
||||
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
|
||||
n += 1
|
||||
candidate = f"{base}_{n}"
|
||||
return candidate
|
||||
|
||||
|
||||
def identity_from_saml(identity, cfg: dict) -> dict:
|
||||
"""Apply the SAML attribute mapping to a validated assertion."""
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
out = {
|
||||
"login": identity.resolve(mapping.get("login", "nameid")),
|
||||
"email": identity.resolve(mapping.get("email", "nameid")),
|
||||
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
|
||||
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
|
||||
"name_id": identity.name_id,
|
||||
}
|
||||
groups = identity.resolve(mapping.get("groups", "groups"))
|
||||
if groups:
|
||||
# Multi-valued SAML attribute: take every value of the resolved source.
|
||||
source = mapping.get("groups", "groups")
|
||||
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
|
||||
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
|
||||
else:
|
||||
out["groups"] = []
|
||||
out["email"] = (out["email"] or "").strip().lower()
|
||||
if "@" not in out["email"]:
|
||||
# NameID may be a persistent opaque id — fall back to login when it is
|
||||
# an email, otherwise leave empty (login will carry the identity).
|
||||
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
|
||||
if not out["full_name"]:
|
||||
out["full_name"] = out["email"] or out["login"]
|
||||
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
|
||||
return out
|
||||
|
||||
|
||||
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
|
||||
"""Resolve/create the local user for an SSO identity. Returns the user dict.
|
||||
|
||||
Raises ``SSOProvisioningError`` when the login must be refused (the
|
||||
caller writes the audit row).
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
login_hint = (identity.get("login") or "").strip()
|
||||
if not email and not login_hint:
|
||||
raise SSOProvisioningError(
|
||||
"SSO assertion carries no usable email/login — check the attribute mapping"
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = None
|
||||
if email:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if not user and login_hint:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
|
||||
|
||||
if user:
|
||||
# §7.1 — email match → merge: the existing account is reused and
|
||||
# tagged with the SSO method (no duplicate account).
|
||||
updates, params = [], []
|
||||
if identity.get("full_name"):
|
||||
updates.append("full_name=?")
|
||||
params.append(identity["full_name"])
|
||||
if email:
|
||||
updates.append("email=?")
|
||||
params.append(email)
|
||||
if identity.get("avatar_url"):
|
||||
updates.append("avatar_url=?")
|
||||
params.append(identity["avatar_url"])
|
||||
updates.append("auth_method=?")
|
||||
params.append(provider_type)
|
||||
updates.append("last_login=?")
|
||||
params.append(str(time.time()))
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
else:
|
||||
if not cfg.get("auto_provision"):
|
||||
raise SSOProvisioningError(
|
||||
"No local account for this SSO identity and auto-provisioning is disabled"
|
||||
)
|
||||
base_login = login_hint or email
|
||||
login = _unique_login(conn, base_login)
|
||||
conn.execute(
|
||||
"""INSERT INTO users
|
||||
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)""",
|
||||
(
|
||||
login,
|
||||
identity.get("full_name") or email or login,
|
||||
email,
|
||||
identity.get("avatar_url") or "",
|
||||
provider_type,
|
||||
str(time.time()),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise SSOProvisioningError("Could not create or load the SSO user")
|
||||
user_dict = dict(row)
|
||||
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
|
||||
return user_dict
|
||||
|
||||
|
||||
def sso_identifier_field(identity: dict) -> str:
|
||||
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
|
||||
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
|
||||
groups = identity.get("groups") or []
|
||||
if groups:
|
||||
return f"{ident}|{json.dumps(groups)}"
|
||||
return ident
|
||||
|
||||
|
||||
def safe_next_path(candidate: str | None) -> str:
|
||||
"""Sanitize the post-login redirect target (open-redirect guard)."""
|
||||
if not candidate:
|
||||
return "/workspaces"
|
||||
candidate = str(candidate)
|
||||
if not candidate.startswith("/") or candidate.startswith("//"):
|
||||
return "/workspaces"
|
||||
parsed = urllib.parse.urlsplit(candidate)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return "/workspaces"
|
||||
return candidate
|
||||
|
||||
|
||||
# ── Anti-replay request store ──────────────────────────────────────────────
|
||||
|
||||
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
|
||||
|
||||
|
||||
def create_request(kind: str, *, request_id: str, relay_state: str = "",
|
||||
code_verifier: str = "", next_path: str = "/workspaces") -> None:
|
||||
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
|
||||
from app.db import get_conn
|
||||
|
||||
purge_stale_requests()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO sso_requests
|
||||
(id, kind, relay_state, code_verifier, next_path, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
|
||||
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
|
||||
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if relay_state and row["relay_state"] and not secrets.compare_digest(
|
||||
row["relay_state"], relay_state
|
||||
):
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def peek_request(kind: str, request_id: str) -> dict | None:
|
||||
"""Read a request without consuming it (CSRF check before heavy validation)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def was_consumed(kind: str, request_id: str) -> bool:
|
||||
"""True when this single-use request id was already spent (replay)."""
|
||||
if not request_id:
|
||||
return False
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def purge_stale_requests() -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("purge_stale_requests")
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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 ""
|
||||
@@ -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}
|
||||
@@ -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;}
|
||||
|
||||
@@ -500,7 +500,7 @@
|
||||
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
|
||||
llmModel: '', llmKeys: [], defaultProvider: 'offline',
|
||||
activeContext: null, ctxPinnedKey: null,
|
||||
contextChips: [], skills: [],
|
||||
contextChips: [], skills: [], gallery: [],
|
||||
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
@@ -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){
|
||||
@@ -811,6 +811,42 @@
|
||||
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
|
||||
self.skills = d.skills || [];
|
||||
}).catch(function(){ self.skills = []; });
|
||||
// Galerie de skills (marketplace, v6.6.0) — presets installables en 1 clic.
|
||||
fetch('/api/agent/skills/gallery').then(function(r){return r.json()}).then(function(d){
|
||||
var installed = {};
|
||||
(self.skills || []).forEach(function(s){ installed[String(s.name).toLowerCase()] = true; });
|
||||
self.gallery = (d.gallery || []).filter(function(g){
|
||||
return !installed[String(g.name).toLowerCase()];
|
||||
});
|
||||
}).catch(function(){ self.gallery = []; });
|
||||
},
|
||||
// Installe un preset de galerie puis l'épingle au contexte (phase 5).
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
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){
|
||||
if(!res.ok){
|
||||
self.toast((res.d && res.d.detail) || 'Installation impossible.', true);
|
||||
return;
|
||||
}
|
||||
self.toast('Skill « ' + (res.d.name || name || slug) + ' » installée.');
|
||||
self.fetchSkills();
|
||||
self._pinDbSkill(res.d.id, res.d.name || name || slug, res.d.icon || icon || '📦');
|
||||
}).catch(function(){ self.toast('Installation impossible (réseau).', true); });
|
||||
},
|
||||
// Épingle un skill enregistré (id DB) dans les chips de contexte.
|
||||
_pinDbSkill(id, name, icon){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === id; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); return; }
|
||||
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
|
||||
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
loadConversations(){
|
||||
var self = this;
|
||||
@@ -952,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;
|
||||
@@ -969,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;
|
||||
@@ -991,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(){});
|
||||
},
|
||||
|
||||
@@ -1254,7 +1290,11 @@
|
||||
items.forEach(function(o){ o._i = seq++; out.push(o); });
|
||||
}
|
||||
var skillsPresent = false, admPresent = false;
|
||||
var built = [], dbs = [], adm = [];
|
||||
var built = [], dbs = [], adm = [], gal = [];
|
||||
var installedNames = {};
|
||||
for(var j=0;j<(this.skills||[]).length;j++){
|
||||
installedNames[String(this.skills[j].name).toLowerCase()] = true;
|
||||
}
|
||||
for(var i=0;i<FD_SKILLS.length;i++){
|
||||
if(qmatch(FD_SKILLS[i].cmd)){
|
||||
var s = FD_SKILLS[i];
|
||||
@@ -1267,6 +1307,15 @@
|
||||
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
|
||||
}
|
||||
}
|
||||
// Galerie (marketplace) — presets pas encore installés.
|
||||
for(i=0;i<(this.gallery||[]).length;i++){
|
||||
var g = this.gallery[i];
|
||||
if(installedNames[String(g.name).toLowerCase()]) continue;
|
||||
if(qmatch(g.slug)){
|
||||
gal.push({key:'gal-'+g.slug, cmd:'/'+g.slug, desc:(g.description || 'Skill galerie'),
|
||||
icon:(g.icon || '📦'), _type:'gallery', slug:g.slug, skillName:g.name});
|
||||
}
|
||||
}
|
||||
for(i=0;i<FD_ADMIN_CMDS.length;i++){
|
||||
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
|
||||
}
|
||||
@@ -1276,7 +1325,11 @@
|
||||
out.push({_sep:'Skills', key:'sep-skills'});
|
||||
pushItems(built.concat(dbs));
|
||||
}
|
||||
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(gal.length){
|
||||
out.push({_sep:'Galerie', key:'sep-gal'});
|
||||
pushItems(gal);
|
||||
}
|
||||
if((skillsPresent || gal.length) && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(admPresent) pushItems(adm);
|
||||
this._slashSeq = seq;
|
||||
return out;
|
||||
@@ -1302,17 +1355,14 @@
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'gallery'){
|
||||
this.installGallerySkill(c.slug, c.icon, c.skillName);
|
||||
this.cmdFocus = -1; this.slashDismiss = false;
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'db'){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
|
||||
else {
|
||||
var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
|
||||
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
}
|
||||
this._pinDbSkill(c.skillId, c.cmd, c.icon);
|
||||
} else {
|
||||
var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; });
|
||||
@@ -1782,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; }
|
||||
@@ -1880,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
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
|
||||
var self = this;
|
||||
// Soft-delete all selected items
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
}
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
@@ -1124,7 +1124,8 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
|
||||
body: JSON.stringify({icon: icon})
|
||||
});
|
||||
if (!r.ok) throw new Error('icon update failed');
|
||||
@@ -1206,7 +1207,7 @@ window._wsInitData = (function() {
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1343,7 +1344,7 @@ window._wsInitData = (function() {
|
||||
if (!newName) return;
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1523,7 +1524,7 @@ window._wsInitData = (function() {
|
||||
this.renamingId = null;
|
||||
if (!n || n === node.name) return;
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1578,7 +1579,7 @@ window._wsInitData = (function() {
|
||||
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
this.clipboard.forEach(function(id) {
|
||||
fetch('/api/local-workspace/items/' + id + '/move', {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({parent_id: targetId})
|
||||
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
||||
});
|
||||
@@ -1589,7 +1590,7 @@ window._wsInitData = (function() {
|
||||
// ── Duplicate ──
|
||||
async duplicateItem(node) {
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
||||
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
||||
});
|
||||
@@ -1617,7 +1618,7 @@ window._wsInitData = (function() {
|
||||
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
||||
this.undoVisible = true;
|
||||
// Delete via API
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (!r.ok) { this.undoVisible = false; return; }
|
||||
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
||||
this._reloadAfterAction();
|
||||
@@ -1642,7 +1643,7 @@ window._wsInitData = (function() {
|
||||
self._reloadAfterAction();
|
||||
return;
|
||||
}
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
|
||||
.then(function(r) { if (r.ok) restored++; })
|
||||
.finally(function() { restoreOne(i + 1); });
|
||||
}
|
||||
@@ -1940,7 +1941,7 @@ window._wsInitData = (function() {
|
||||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2024,7 +2025,7 @@ window._wsInitData = (function() {
|
||||
if (!n||!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:n})
|
||||
});
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
@@ -2037,7 +2038,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async doDelete() {
|
||||
if (!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
},
|
||||
|
||||
@@ -2230,7 +2231,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2326,7 +2327,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: tagName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2345,7 +2346,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async removeTag(itemId, tagId) {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2491,7 +2492,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ parent_id: parentId || null })
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
+337
-30
@@ -153,6 +153,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -809,35 +810,57 @@
|
||||
<!-- Admin: Audit Log -->
|
||||
<div x-show="activeSection==='admin-audit'">
|
||||
<h2>Audit Log</h2>
|
||||
<p class="section-desc">Login history and security events.</p>
|
||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||||
</template>
|
||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
|
||||
<span style="flex:1;"></span>
|
||||
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>User</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
<th>Source</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>IP</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="e in auditEntries" :key="e.id">
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
|
||||
<td>
|
||||
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
|
||||
</td>
|
||||
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
|
||||
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
|
||||
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="auditEntries.length === 0">
|
||||
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
|
||||
<tr x-show="!auditLogs.length && !auditLoading">
|
||||
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||||
</div>
|
||||
<style>
|
||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
|
||||
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
|
||||
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Backups -->
|
||||
@@ -870,6 +893,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: SSO / Enterprise (v6.7.0) -->
|
||||
<div x-show="activeSection==='admin-sso'">
|
||||
<h2>SSO / Enterprise</h2>
|
||||
<p class="section-desc">Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
|
||||
<div class="llm-summary-icon">🔐</div>
|
||||
<div class="llm-summary-body">
|
||||
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
|
||||
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
|
||||
</div>
|
||||
<div class="llm-summary-side">
|
||||
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider -->
|
||||
<div class="setting-group">
|
||||
<h3>Fournisseur</h3>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
|
||||
<option value="saml">SAML 2.0</option>
|
||||
<option value="oidc">OpenID Connect</option>
|
||||
</select>
|
||||
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SAML -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
|
||||
<h3>Configuration SAML</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
|
||||
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
|
||||
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
|
||||
</label>
|
||||
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<span>SP metadata à donner à l'IdP :</span>
|
||||
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
|
||||
<h3>Configuration OpenID Connect</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
|
||||
<div class="pw-wrapper" style="max-width:560px;">
|
||||
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
|
||||
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
|
||||
style="padding-right:36px;">
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning -->
|
||||
<div class="setting-group">
|
||||
<h3>Provisioning & accès</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
|
||||
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
|
||||
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
|
||||
</label>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
|
||||
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
|
||||
<option value="">— Aucun —</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group mapping -->
|
||||
<div class="setting-group">
|
||||
<h3>Groupes IdP → rôles workspace</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(g, i) in ssoGroups" :key="i">
|
||||
<tr>
|
||||
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
|
||||
<option value="">Espace par défaut</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</td>
|
||||
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
|
||||
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
|
||||
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
|
||||
</div>
|
||||
|
||||
<!-- Agent & IA: per-user provider keys + admin global default -->
|
||||
<div x-show="activeSection==='llm'">
|
||||
<h2>Agent & IA</h2>
|
||||
@@ -1067,6 +1225,7 @@ function settingsInit() {
|
||||
adminUsers: [],
|
||||
adminStats: {},
|
||||
auditEntries: [],
|
||||
auditLogs: [],
|
||||
showCreateUser: false,
|
||||
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
||||
editingUser: null,
|
||||
@@ -1115,6 +1274,17 @@ function settingsInit() {
|
||||
backupMsg: '',
|
||||
backupOk: false,
|
||||
|
||||
// v6.7.0 SSO / Enterprise (admin)
|
||||
ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
|
||||
ssoAttrJson: '{}',
|
||||
ssoGroups: [],
|
||||
ssoWorkspaces: [],
|
||||
ssoSource: 'db',
|
||||
ssoProvisioned: 0,
|
||||
ssoSaving: false,
|
||||
ssoMsg: '',
|
||||
ssoOk: false,
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
@@ -1167,7 +1337,7 @@ function settingsInit() {
|
||||
var n = this.newTagName.trim();
|
||||
if (!n) return;
|
||||
var r = await fetch('/api/settings/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n, color: this.newTagColor})
|
||||
});
|
||||
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||
@@ -1175,7 +1345,7 @@ function settingsInit() {
|
||||
|
||||
async updateTagColor(id, color) {
|
||||
await fetch('/api/settings/tags/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
await this.loadTags();
|
||||
@@ -1183,7 +1353,7 @@ function settingsInit() {
|
||||
|
||||
async deleteTag(id) {
|
||||
if (!confirm('Delete this tag?')) return;
|
||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
@@ -1205,7 +1375,7 @@ function settingsInit() {
|
||||
this.renamingTag = null; return;
|
||||
}
|
||||
await fetch('/api/settings/tags/' + tag.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
this.renamingTag = null;
|
||||
@@ -1247,11 +1417,41 @@ function settingsInit() {
|
||||
},
|
||||
|
||||
async loadAdminAudit() {
|
||||
await this.loadAuditLogs();
|
||||
},
|
||||
|
||||
// ── Unified Audit API (v7.3.0) ──
|
||||
auditSource: 'all',
|
||||
auditActor: '',
|
||||
auditAction: '',
|
||||
auditOffset: 0,
|
||||
auditPageSize: 50,
|
||||
auditLoading: false,
|
||||
auditHasMore: false,
|
||||
async loadAuditLogs(reset) {
|
||||
if (reset !== false) { this.auditOffset = 0; }
|
||||
this.auditLoading = true;
|
||||
try {
|
||||
var r = await this.adminFetch('/api/admin/audit?limit=200');
|
||||
var d = await r.json();
|
||||
this.auditEntries = d.entries || [];
|
||||
} catch(e) { this.auditEntries = []; }
|
||||
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
|
||||
if (this.auditActor) params.set('actor', this.auditActor);
|
||||
if (this.auditAction) params.set('action', this.auditAction);
|
||||
const r = await fetch('/api/v2/audit/logs?' + params.toString());
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
const d = await r.json();
|
||||
const logs = d.logs || [];
|
||||
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
|
||||
this.auditHasMore = logs.length >= this.auditPageSize;
|
||||
} catch(e) {
|
||||
if (reset !== false) this.auditLogs = [];
|
||||
this.auditHasMore = false;
|
||||
}
|
||||
this.auditLoading = false;
|
||||
},
|
||||
auditCsvUrl() {
|
||||
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
|
||||
if (this.auditActor) params.set('actor', this.auditActor);
|
||||
if (this.auditAction) params.set('action', this.auditAction);
|
||||
return '/api/v2/audit/logs?' + params.toString();
|
||||
},
|
||||
|
||||
async adminCreateUser() {
|
||||
@@ -1448,7 +1648,7 @@ function settingsInit() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1477,7 +1677,7 @@ function settingsInit() {
|
||||
if (f.models && f.models.length) body.models = f.models;
|
||||
var r = await fetch('/api/agent/keys/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1513,7 +1713,7 @@ function settingsInit() {
|
||||
if (f.api_key) body.api_key = f.api_key;
|
||||
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1539,7 +1739,7 @@ function settingsInit() {
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
|
||||
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
||||
@@ -1699,7 +1899,7 @@ function settingsInit() {
|
||||
if (!file) return;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
||||
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||
@@ -1710,7 +1910,7 @@ function settingsInit() {
|
||||
async selectAvatarColor(color) {
|
||||
this.avatarColor = color;
|
||||
var r = await fetch('/api/settings/avatar-color', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
if (r.ok) { this.avatarUrl = ''; }
|
||||
@@ -1855,7 +2055,7 @@ function settingsInit() {
|
||||
// ── v5.2.0 API tokens ──
|
||||
async loadApiTokens() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
|
||||
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.apiTokens = d.tokens || [];
|
||||
} catch(e) { this.apiTokens = []; }
|
||||
@@ -1865,7 +2065,7 @@ function settingsInit() {
|
||||
if (!name) return;
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
||||
this.newToken = d;
|
||||
@@ -1935,6 +2135,113 @@ function settingsInit() {
|
||||
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
|
||||
finally { this.clipTesting = false; }
|
||||
},
|
||||
// ── v6.7.0 SSO / Enterprise (admin) ──
|
||||
ssoStatusTitle() {
|
||||
if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
|
||||
if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
|
||||
return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
|
||||
},
|
||||
ssoStatusDesc() {
|
||||
var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
|
||||
return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
|
||||
},
|
||||
ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
|
||||
async copySsoMetadata() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.ssoMetadataUrl());
|
||||
if (typeof toast === 'function') toast('SP metadata URL copiée');
|
||||
} catch(e) {}
|
||||
},
|
||||
async loadSsoConfig() {
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.ssoSource = d.source || 'db';
|
||||
this.ssoProvisioned = d.provisioned_users || 0;
|
||||
if (d.configured === false) {
|
||||
this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
|
||||
} else {
|
||||
this.ssoCfg = {
|
||||
id: d.id || null,
|
||||
provider_type: d.provider_type || 'saml',
|
||||
name: d.name || '',
|
||||
entity_id: d.entity_id || '',
|
||||
sso_url: d.sso_url || '',
|
||||
slo_url: d.slo_url || '',
|
||||
x509_certificate: d.x509_certificate || '',
|
||||
issuer_url: d.issuer_url || '',
|
||||
client_id: d.client_id || '',
|
||||
client_secret: '',
|
||||
client_secret_set: !!d.client_secret_set,
|
||||
scope: d.scope || 'openid profile email',
|
||||
attribute_mapping: d.attribute_mapping || {},
|
||||
auto_provision: !!d.auto_provision,
|
||||
sso_only: !!d.sso_only,
|
||||
sign_requests: !!d.sign_requests,
|
||||
default_workspace_id: d.default_workspace_id || ''
|
||||
};
|
||||
this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
|
||||
this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
|
||||
if (w.ok) {
|
||||
var wd = await w.json();
|
||||
this.ssoWorkspaces = wd.workspaces || [];
|
||||
this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
async saveSsoConfig() {
|
||||
this.ssoMsg = '';
|
||||
var amap = {};
|
||||
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
|
||||
catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
|
||||
if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
|
||||
this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
|
||||
}
|
||||
this.ssoSaving = true;
|
||||
var payload = Object.assign({}, this.ssoCfg, {
|
||||
attribute_mapping: amap,
|
||||
groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
|
||||
auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
|
||||
sso_only: this.ssoCfg.sso_only ? 1 : 0,
|
||||
sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
|
||||
default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
|
||||
});
|
||||
delete payload.id;
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||||
var d = null; try { d = await r.json(); } catch(e) {}
|
||||
if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
|
||||
else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
finally { this.ssoSaving = false; }
|
||||
},
|
||||
async disableSso() {
|
||||
if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
|
||||
if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
},
|
||||
async syncSsoGroups() {
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
|
||||
var d = null; try { d = await r.json(); } catch(e) {}
|
||||
if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
},
|
||||
addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
|
||||
removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
|
||||
// ── v5.2.0 Backups (admin) ──
|
||||
async loadBackups() {
|
||||
try {
|
||||
|
||||
@@ -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éé 🎉');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
)
|
||||
+93
-6
@@ -2,9 +2,17 @@
|
||||
|
||||
> **Statut** : ✅ **IMPLÉMENTÉ (v6.3.0, 2026-09-21)** — l'API publique `/api/v2` est livrée :
|
||||
> routeur `app/routers/api_v2.py`, helpers `app/services/api_v2_helpers.py`, migration 20,
|
||||
> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json` — 402 chemins), 24 tests dédiés.
|
||||
> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json`), 24 tests dédiés.
|
||||
> **v6.6.0 (2026-09-24)** — **Agent phase 5** : wrappers agent (`/api/v2/agents/*`) +
|
||||
> marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique
|
||||
> partagée `app/services/skill_gallery.py`, OpenAPI régénéré (**427 chemins**), 15 tests dédiés
|
||||
> (`tests/test_v66_agent_api.py`). Voir §2.4.
|
||||
> **v6.7.0 (2026-09-24)** — **SSO / SAML + OIDC entreprise** : parcours navigateur
|
||||
> `/auth/saml/*` + `/auth/oidc/*` (hors scope `/api/v2`), API admin de configuration
|
||||
> `/api/v2/sso/*` (session + admin + CSRF), logique partagée `app/services/sso_provisioning.py`,
|
||||
> OpenAPI régénéré, 38 tests dédiés (`tests/test_v67_sso.py`). Voir §2.5.
|
||||
> Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception.
|
||||
> **Dernière mise à jour** : 2026-09-21
|
||||
> **Dernière mise à jour** : 2026-09-24
|
||||
> **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation.
|
||||
>
|
||||
> **Reste reporté (v6.4)** : webhooks HMAC `X-FlowDeck-Signature` + retry 2s/10s/60s + événements étendus ;
|
||||
@@ -208,9 +216,88 @@ POST /api/move, /col-mapping (GET/DELETE/POST), /board-config/{owner}/{repo} (GE
|
||||
- Pas de pagination, filtres, tri.
|
||||
- Pas de documentation OpenAPI en production (`docs_url` n'est actif qu'en DEBUG).
|
||||
|
||||
---
|
||||
### 2.4 API publique v2 — Agent & Skill marketplace (`app/routers/api_v2_agent.py`, v6.6.0)
|
||||
|
||||
## 3. Conventions cibles pour l'API v2
|
||||
> **Agent phase 5 « Plateforme »** : permettre à une intégration tierce de piloter
|
||||
> FlowDeck Agent et d'installer/partager des skills, sans session navigateur.
|
||||
|
||||
**Agents, conversations & runs**
|
||||
| Méthode | Route | Scope | Description |
|
||||
|---------|-------|-------|-------------|
|
||||
| GET | `/api/v2/agents` | read | Liste les agents (pagination `limit`/`offset`) |
|
||||
| POST | `/api/v2/agents` | write | Crée un agent (`name`, `system_instructions`, `model`, `scope`, `trigger`) |
|
||||
| GET/PUT/DELETE | `/api/v2/agents/{id}` | read / write | Détail, mise à jour, suppression |
|
||||
| GET | `/api/v2/agents/conversations` | read | Conversations **de l'utilisateur du token** |
|
||||
| POST | `/api/v2/agents/conversations` | write | Crée une conversation (`agent_id` optionnel) |
|
||||
| GET/DELETE | `/api/v2/agents/conversations/{id}` | read / write | Conversation + messages / suppression |
|
||||
| POST | `/api/v2/agents/conversations/{id}/run` | write | **Run synchrone JSON** (le flux SSE reste interne) |
|
||||
| GET | `/api/v2/agents/conversations/{id}/actions` | read | Journal d'audit (`agent_actions`, snapshots) |
|
||||
| POST | `/api/v2/agents/actions/{id}/undo` | write | Rollback d'une action |
|
||||
| POST | `/api/v2/agents/{id}/trigger` | write | Déclenche un agent custom (JSON, synchrone) |
|
||||
|
||||
Réponse de `run` : `{conversation_id, status: completed|failed, final, error, reasoning[], actions[], events[], duration_ms}` —
|
||||
HTTP 500 quand `status = failed`. Les événements `events[]` sont ceux du flux SSE
|
||||
(`reasoning`, `action`, `final`, `notice`, `error`), à plat : `{"type": "final", "content": ...}`.
|
||||
|
||||
**Skill marketplace**
|
||||
| Méthode | Route | Scope | Description |
|
||||
|---------|-------|-------|-------------|
|
||||
| GET/POST | `/api/v2/skills` | read / write | Liste / création (`409` si nom déjà présent) |
|
||||
| GET/DELETE | `/api/v2/skills/{id}` | read / write | Détail / suppression |
|
||||
| GET | `/api/v2/skills/{id}/export` | read | **Document portable** `{format, version, skill{...}}` |
|
||||
| POST | `/api/v2/skills/import` | write | Import (identique → `409`, `overwrite: true` → maj) |
|
||||
| GET | `/api/v2/skills/gallery` | read | Presets embarqués (6) |
|
||||
| POST | `/api/v2/skills/gallery/{slug}/install` | write | Installe un preset dans le workspace |
|
||||
| POST | `/api/v2/skills/{id}/apply` | write | Ouvre une conversation préchargée du skill |
|
||||
|
||||
Mêmes endpoints (session cookie) côté interne : `/api/agent/skills/gallery`,
|
||||
`/api/agent/skills/{id}/export`, `/api/agent/skills/import`, `DELETE /api/agent/skills/{id}` —
|
||||
**même implémentation** via `app/services/skill_gallery.py`.
|
||||
|
||||
**Règles spécifiques agent**
|
||||
1. La **propriété** des conversations est vérifiée (`user_id` du token) : un token ne voit
|
||||
jamais les conversations d'un autre utilisateur (`404`, pas `403`, pour ne pas fuiter).
|
||||
2. Le run est **synchrone** : le moteur (`AgentEngine`), l'ACL (`PermissionManager`), le journal
|
||||
`agent_actions` et les webhooks sont exactement ceux de l'UI — aucun second chemin.
|
||||
3. Cycle de vie webhook : `agent.run.started` → `agent.run.finished` | `agent.run.failed`
|
||||
(catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible).
|
||||
4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …).
|
||||
|
||||
### 2.5 SSO / Enterprise auth (`app/routers/sso.py`, v6.7.0)
|
||||
|
||||
> **Authentification fédérée** : un IdP d'entreprise (SAML 2.0 ou OpenID Connect + PKCE)
|
||||
> ouvre une session FlowDeck ; les comptes sont créés au premier login et les groupes de
|
||||
> l'IdP deviennent des rôles workspace. Design : `docs/V6_SSO_SAML_Enterprise_Auth.md`.
|
||||
|
||||
**Parcours navigateur (session cookie, CSRF exclu — POST IdP cross-site)**
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| GET | `/auth/saml/login` | Redirection SP-initiée vers l'IdP (`RelayState` = next sûr) |
|
||||
| POST | `/auth/saml/callback` | Assertion Consumer Service — signature/aud/dest/InResponseTo validés |
|
||||
| GET | `/auth/saml/metadata` | Métadonnées SP XML (EntityID, ACS, SLO, certificat) |
|
||||
| GET | `/auth/saml/logout` | SLO — relaye `SAMLRequest` à l'IdP puis détruit la session (POST accepté pour l'IdP) |
|
||||
| GET | `/auth/oidc/login` | Redirection authorize (`state` + `code_verifier` en DB) |
|
||||
| GET | `/auth/oidc/callback` | Code → token → userinfo ; ID token vérifié (JWKS, aud/iss/nonce/exp) |
|
||||
| GET/POST | `/auth/oidc/logout` | Déconnexion OIDC (relaye à l'IdP si `end_session_endpoint`) |
|
||||
|
||||
**API admin (session + rôle admin + header `X-CSRF-Token`)**
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| GET | `/api/v2/sso/providers` | **Public** (page de login) : `{providers[{type,name,icon,login_url}], sso_only, base_url}` |
|
||||
| GET | `/api/v2/sso/config` | Config publique (`{configured, source: db\|env, client_secret_set, provisioned_users}` — **jamais** le secret) |
|
||||
| POST/PUT | `/api/v2/sso/config` | Crée/met à jour (`{configured:false}` = actif) ; champ secret vide = conserver |
|
||||
| DELETE | `/api/v2/sso/config` | Désactive le SSO (les comptes SSO existants restent) |
|
||||
| GET | `/api/v2/sso/workspaces` | Épingles pour le mapping (workspaces + `provisioned_users`) |
|
||||
| POST | `/api/v2/sso/sync` | Re-synchronise les groupes de tous les utilisateurs SSO |
|
||||
| GET | `/api/v2/sso/history` | Journal d'audit des tentatives (`limit` ≤ 200) |
|
||||
|
||||
Règles : secrets chiffrés Fernet (`sso_config`), anti-replay `sso_requests` (TTL 15 min),
|
||||
historique `sso_login_history` (succès/échecs), mode SSO only (login local refusé sauf admins),
|
||||
bootstrap possible par variables `SSO_*` du `.env` (la config admin prime).
|
||||
|
||||
---
|
||||
|
||||
### 3.1 Auth & tokens
|
||||
|
||||
@@ -606,7 +693,7 @@ EVENTS = [
|
||||
"mention.created", "notification.created",
|
||||
"sprint.created", "sprint.updated", "sprint.completed",
|
||||
"share.created", "share.revoked", "page.published", "page.unpublished",
|
||||
"agent.run.completed",
|
||||
"agent.run.started", "agent.run.finished", "agent.run.failed",
|
||||
```
|
||||
|
||||
**Payload type** :
|
||||
@@ -685,7 +772,7 @@ EVENTS = [
|
||||
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
|
||||
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
|
||||
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins).
|
||||
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
|
||||
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
|
||||
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
|
||||
|
||||
|
||||
@@ -663,7 +663,16 @@ Phase 5 — Custom Agents
|
||||
1. Scope + trigger_json
|
||||
2. Scheduler (lifespan FastAPI)
|
||||
3. Déclencheurs webhook (via webhooks.py)
|
||||
4. Mode approbation (auto/confirm)
|
||||
4. Mode approbation (auto/confirm) ✅ (v4.10.0)
|
||||
|
||||
> **Note numérotation** — ce document suit l'ordre historique d'implémentation.
|
||||
> Le plan officiel à 5 phases de `ROADMAP.md` se lit : phase 3 « Outils avancés » ✅ v4.10.0 ·
|
||||
> phase 4 « Autonomie » (agents, skills, déclencheurs) ✅ v4.10.0 ·
|
||||
> **phase 5 « Plateforme » ✅ v6.6.0** — API publique `/api/v2/agents/*` (run synchrone JSON,
|
||||
> journal + rollback, `trigger` externe), marketplace de skills (export/import portable,
|
||||
> galerie de 6 presets, section « Galerie » dans la palette `/` du panneau) et webhooks de cycle de vie
|
||||
> `agent.run.started` → `agent.run.finished` | `agent.run.failed`.
|
||||
> Référence : `docs/API_GUIDE_V6.md` §2.4 · tests : `tests/test_v66_agent_api.py`.
|
||||
|
||||
|
||||
-------------------------
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# V6.0.0 — SSO / SAML : Enterprise Authentication
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Statut** : ✅ **Livré en v6.7.0** (2026-09-24) — conception historique ci-dessous
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-sso-saml` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant)
|
||||
@@ -181,7 +181,7 @@ SSO_PROVIDER=saml
|
||||
SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/
|
||||
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
|
||||
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout
|
||||
SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_X509_CERTIFICATE="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
|
||||
SSO_AUTO_PROVISION=true
|
||||
SSO_DEFAULT_WORKSPACE_ID=1
|
||||
@@ -432,18 +432,20 @@ ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
|
||||
|
||||
## 9. Checklist d'implémentation
|
||||
|
||||
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2
|
||||
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
|
||||
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC
|
||||
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users`
|
||||
5. **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. **Extension `settings.html`** — UI admin SSO
|
||||
7. **Extension `login.html`** — boutons SSO
|
||||
8. **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. **Security** — validation assertions, rate limiting, audit log
|
||||
10. **Tests** — tous les scénarios SSO
|
||||
11. **Documentation utilisateur** — `/help` section SSO setup
|
||||
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt
|
||||
> ✅ **Tout est livré en v6.7.0** (`tests/test_v67_sso.py`, 38 tests) :
|
||||
|
||||
1. ✅ **`auth/providers/saml_provider.py`** — wrapper python3-saml (`app/auth/providers/saml_provider.py`)
|
||||
2. ✅ **`auth/providers/oidc_provider.py`** — wrapper OIDC authlib (`app/auth/providers/oidc_provider.py`)
|
||||
3. ✅ **`routers/sso.py`** — endpoints SSO/OIDC (`app/routers/sso.py` + API admin `/api/v2/sso/*`)
|
||||
4. ✅ **Migration 23 `sso_config`** + `sso_login_history` + `sso_requests` + colonne `auth_method` sur `users` (déjà présente)
|
||||
5. ✅ **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. ✅ **Extension `settings.html`** — UI admin SSO (onglet « SSO / Enterprise »)
|
||||
7. ✅ **Extension page de login** — boutons SSO (nom dynamique)
|
||||
8. ✅ **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. ✅ **Security** — validation assertions (sign/aud/dest/InResponseTo), anti-replay `sso_requests`, rate limit login, historique
|
||||
10. ✅ **Tests** — `tests/test_v67_sso.py` : 38 scénarios (flots SAML/OIDC, négatifs, groupes, sso_only, SLO)
|
||||
11. ✅ **Documentation utilisateur** — `/help` section « SSO (Enterprise) »
|
||||
12. ✅ **Dépendance** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0` dans requirements.txt
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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é.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+5197
-48
File diff suppressed because it is too large
Load Diff
-1
@@ -1 +0,0 @@
|
||||
../@playwright/test/cli.js
|
||||
-1
@@ -1 +0,0 @@
|
||||
../playwright-core/cli.js
|
||||
-50
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user