Compare commits

...
20 Commits
Author SHA1 Message Date
bruno c718fe06de fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
  ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
  `script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
  sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
  scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
  helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
  (chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
  : htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
  restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
  : vues chart/map déjà BLOQUÉES par la CSP depuis toujours
  (commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
  JS (A27), resserrer img-src/connect-src

test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)

suite **1037/1037** · `ruff check app tests` OK · docs à jour
2026-10-01 10:41:39 -04:00
bruno f706424f90 fix: A31 — transaction par migration + helper columns() (v7.6.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
  commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
  (isolation_level legacy) : un échec au milieu laissait un schéma partiel
  commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
  Si une transaction englobante subsiste (init_db commit juste avant), on la
  vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
  : 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
  éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
  `table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
  migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.

tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name

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

tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired

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

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

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00
bruno ffa1fa89ab fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00
bruno 3ad2605c9e fix: A14 — fin du fallback « row admin » sur l'agent (v7.3.7)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 20m33s
FlowDeck CI / docker (push) Canceled after 0s
- `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur
  `SELECT id FROM users WHERE login='admin'`
- `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers`
  et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`)
  exigent une session admin : 401 sans session, 403 non-admin
- `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants
  dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le
  provider par défaut du produit (commentaire `ponytail:` pour la fermeture)
- +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK
2026-10-01 07:53:06 -04:00
bruno 1f705ce512 fix: A19 terminé — plus aucun préfixe cookie-auth exempt du CSRF (v7.3.6)
FlowDeck CI / test (push) Successful in 20m10s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
- 46 appels non-GET équipés de `X-CSRF-Token` (expression cookie en ligne,
  portée indifférente) : agent_panel (9), settings (12), local_workspace (15),
  gitea_workspace (4), workspace (2), workspaces (5), library (2), welcome (2 en v7.3.5)
- 5 derniers préfixes sortis d'`EXCLUDED_PATHS` : /api/workspace (+/api/workspaces),
  /api/local-workspace, /api/settings, /api/gitea, /api/agent
- il ne reste exempté que : Bearer (/api/webhook, /api/v1, /api/v2, /scim/v2),
  callbacks /auth/*, pages publiques (/s/, /f/), /api/csrf-token et /api/frontend-error
- vérif : `node --check` des <script> des 39 templates → 0 échec (avant et après)
- tests : `anon_csrf()` sur les 5 tests d'anonymat devenus CSRF-first
- suite **1026/1026** · `ruff check app tests` OK
2026-10-01 07:41:23 -04:00
bruno cf76e00f12 docs(roadmap): A19 — 49 fetch restants (compte exact) au lieu de 51
FlowDeck CI / docker (push) Successful in 1m48s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m14s
2026-09-30 23:38:28 -04:00
bruno 0861f1fdbf fix: A19 (partiel) — CSRF réservé aux vrais cas d'exemption (v7.3.5)
- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes
  n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS`
  (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`,
  `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`,
  `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`)
- `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header
  (`adminFetch` prouve que `/api/admin` était déjà couvert)
- reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`),
  `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch
- tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de
  route du 403 middleware — 4 tests d'anonymat ajustés
- suite **1026/1026** · `ruff check app tests` OK
2026-09-30 23:38:03 -04:00
bruno 72fcef2ba9 fix: A16 — ACL sur l'export et les pièces jointes (v7.3.4)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m5s
- `export.py` : `_load_page_or_404(request, page_id)` — 401 sans session,
  404 si `PermissionManager.can_view_page` refuse (les 4 formats d'export)
- `dashboard.py` : helper `_require_page_view` sur `GET /api/pages/{id}/download`
  et `GET /api/pages/{id}/file-content`
- +1 test de non-régression → suite **1026/1026**, `ruff check app tests` OK
2026-09-30 23:20:26 -04:00
bruno 5a537f5dc3 fix: A12–A24 — SSRF, auth routes legacy, uploads, N+1 et routes doublonnes (v7.3.3)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / test (push) Successful in 20m45s
- A12 — `og_fetcher` : GET sans `follow_redirects`, `_is_public_host` revérifié à
  chaque saut (max 5) ; `POST /board/api/og/metadata` → 400 sur hôte privé/loopback
- A13 — router automations sous `Depends(_require_session)` (CRUD, run,
  press-button) + `created_by` sans fallback ; action `webhook` validée par
  `_is_public_host` avant POST (SSRF)
- A15 — webhooks sortants : `_require_admin` sur GET/POST/DELETE + `_is_public_host`
  sur l'URL en création
- A17 — router legacy `/api` sous `Depends(_require_session_or_bearer)` (session ou
  Bearer `/api/v1`), allowlist explicite `/api/health` + `/api/frontend-error`
- A22 — les 2 uploads locales : session exigée (`_require_user_id`) + `validate_upload`
  branché (taille + extension) + `FLOWDECK_DATA_DIR` au lieu de `/data` codé en dur
- A23 — N+1 : COUNT→`GROUP BY` (dashboard), cards→`executemany` (board sync),
  duplicata de propriétés→`executemany` + remap des ids par SELECT (collections)
- A24 — 2 routes écrasées supprimées : `GET /api/projects` (api.py) et
  `GET /workspace` (workspace.py) + test « aucun doublon méthode+chemin »
- Tests : +9 dans `tests/test_audit_p0_fixes.py` (SSRF, 401s, validate_upload,
  doublons de routes) ; tests OG sur hôtes résolubles (la garde fait du DNS)
- suite **1025/1025** · `ruff check app tests` OK
2026-09-30 23:12:20 -04:00
bruno 8ab6569974 fix: A11 + A18 — path traversal avatar et XSS/flags sur la vue publique (v7.3.2)
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 22m0s
- A11 : `GET /api/settings/avatar/{filename:path}` → `resolve()` + `relative_to()` (motif de `serve_uploaded_file`), 403 hors de `/data/avatars`
- A18 : `GET /workspace/public/{id}` → 404 HTML explicite pour `permission_type` restricted/private, `html.escape` sur le nom, l'icône et les titres de lignes (le f-string HTML ne passe pas par Jinja2)
- `tests/test_audit_p0_fixes.py` : 3 tests de non-régression (traversal, échappement, hidden restricted)
- ROADMAP A11/A18 cochés · CHANGELOG/WORKLOAD/VERSION → 7.3.2 · suite **1019/1019** · `ruff check app tests` OK
2026-09-30 22:40:34 -04:00
bruno 69a0aceba6 fix: A10 — autoescape Jinja2 activé partout via un Environment partagé
FlowDeck CI / docker (push) Successful in 1m44s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m54s
- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
2026-09-30 22:28:48 -04:00
bruno d76d7943fc docs(roadmap): A3-A8 cochés — bloc fallback admin corrigé, suite 1016/1016
FlowDeck CI / lint (push) Successful in 2m0s
FlowDeck CI / test (push) Successful in 22m31s
FlowDeck CI / docker (push) Successful in 1m45s
Commit d125eb3 (code + tests).
2026-09-30 22:05:06 -04:00
bruno d125eb399e fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00
bruno e6c1f7dbb3 docs(roadmap): A1/A2/A9 cochés — deps, cycle commit+tag v7.3.0, désindexation .db + rotation secret
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m17s
FlowDeck CI / docker (push) Successful in 2m13s
Suite 1016/1016 verts.
2026-09-30 20:20:19 -04:00
bruno 465853ac59 fix(tests): A1 — fin du rebinding app.config.settings dans test_v54 (isolation rétablie, 1016/1016 verts)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / test (push) Failing after 3h8m45s
FlowDeck CI / docker (push) Skipped
Le rebind (`app.config.settings = Settings()`) laissait tous les modules déjà
importés (sso_provisioning, trash, …) sur un objet périmé : le test
test_v67_sso::test_env_config_fallback_when_table_empty échouait dès qu'il
tournait après test_v54 dans le même worker (-n auto). Mutation sur place
comme le préconise conftest.py.
2026-09-30 20:19:09 -04:00
bruno 1706ad1ee9 feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
2026-09-30 20:02:57 -04:00
355 changed files with 15722 additions and 355222 deletions
+7
View File
@@ -14,3 +14,10 @@ build/
node_modules/
Dockerfile
.dockerignore
# A9 — jamais de DB ni de fichiers de test dans l'image
*.db
*.db-*
test-commit.md
upload_test.txt
e2e/
+2 -2
View File
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
DEFAULT_LANG=fr
# ── Database ──
# SQLite (default): sqlite:////data/flowdeck.db
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
# schéma retombe silencieusement sur /data/flowdeck.db).
DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ──
+9
View File
@@ -17,3 +17,12 @@ dist/
.ua/.trash-*/
.ua/.understandignore
uv.lock
# A9 — jamais de DB ni de fichiers de test dans git
*.db
*.db-*
test-commit.md
upload_test.txt
e2e/node_modules/
e2e/shots/
e2e/test-results/
+566
View File
@@ -1,5 +1,571 @@
# Changelog - FlowDeck
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
### Security
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
- Les 74 handlers `onclick=` inline restent fonctionnels via
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
aurait désactivés aussi)
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
`style-src` : les vues chart/map de `collections` les utilisent et étaient
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
vendoriser ces libs puis retirer les hôtes)
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
`img-src`/`connect-src`
### Tests
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
header identique sur tous les scripts inline, nonce différent d'une requête à
l'autre) et page hors template (`/auth/login?provider=local`)
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
laissait un schéma partiel commité SANS ligne de version, et la reprise
rejouait un DDL déjà appliqué
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
`ValueError` sinon) : **25 copies** de
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
se lit dans le set
### Tests
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed
- **A29** — `services/publish.py` partagé : les 3 paires publish/unpublish
(sharing = front, board, v2) déléguent ; 404 partout (board faisait une
mise à jour aveugle), slugify titré unique (board : aléatoire ; v2 : slug
fourni conservé), événements centralisés, board gagne le contrôle de session.
Les bonus divergents disparaissent (`share_mode='anyone'` pour board,
`is_shared=1` pour v2) : le share dialog reste l'unique propriétaire de ces
drapeaux, dépublier ne révoque donc pas un partage manuel. Les listings
`/users/me` ×2 et collections ×3 restent : contrats versionnés distincts
- **A42** — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` →
`settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à
chaque écriture (il ne pouvait que grandir) ; les 29 `Environment(...)`
étaient déjà couverts par A10. **Reste** : client httpx partagé (52 créations,
à faire avec un cache par event loop)
### Security
- **Byproduct A29** — `GET /api/users/me` (v1) et le contexte Jinja de
`/accounts` renvoyaient `SELECT *` sur `users` : **password_hash**,
`login_attempts` et `locked_until` exposés → colonnes whitelistées
(identiques à la liste v2)
### Tests
- `test_publish_service_shared_and_safe` (slug, 404, partage préservé),
`test_users_me_no_secret_columns`, `test_gitea_cache_evicts_expired`
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
### Changed
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
dérivées de `settings.app_base_url` + localhost/origines d'extension
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
explicite ; test `test_cors_no_star`
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
templates, JS, autres CSS et code Python
### Notes
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
automatisé du view-switch
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **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
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
## Quick Start
+201 -16
View File
@@ -942,7 +942,7 @@ Détails livrés :
---
## v6.0.0 — Pro (futur)
## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
@@ -954,6 +954,101 @@ Détails livrés :
---
## v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)
> **Contexte (2026-09-28)** : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité
> (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO).
> Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : **Sites, Forms,
> Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP**.
> Référence : `notion.com/product/features` + `notion.com/releases` (Workers, Meeting Notes → agents, Developer bar, MCP).
### v6.8.0 — Sites & Forms publics ✅ (2026-09-28)
> **Objectif** : publier (site multi-pages) et collecter (formulaires anonymes).
> Parité Notion Sites + Forms. **Doc** : [`docs/V68_Sites_Forms.md`](docs/V68_Sites_Forms.md)
- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s/<slug>/sitemap.xml`, meta robots, preview sociale
- [x] **Gating public** — mot de passe (hash salé `password_utils`, cookie signé 24h), expiry (410), `noindex`, analytics vues (`site_views` : jour + compteur, pas d'IP brute)
- [x] **Routes** — `GET /s/<slug>` (home), `GET /s/<slug>/<page-slug>` (résolution slug + id), `GET|POST /s/<slug>/auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>` (no-auth) + `POST /f/<token>` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
- [x] **Tests** — `tests/test_v68_sites_forms.py` (**20 tests** : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
- [x] **Embeddings** — tables `semantic_embeddings` (`resource_type`, `resource_id`, `chunk_id`, `chunk_text`, `embedding BLOB`, `model hash-256`) + `search_index_state` ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, `embed_texts()` pluggable) ; job incrémental (`updated_at > indexed_at`, batch 50, scheduler 5 min dans le lifespan) + `purge_orphans()`
- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
- [x] **Ask AI** — `POST /api/v2/search/ask` (`{question, workspace_id}` → `{answer_markdown, citations}`) : top-8 chunks autorisés (ACL avant prompt), `LLMClient.complete()` si provider configuré sinon extractif offline avec `[[fdpage:ID]]`, cache 10 min, rate-limit 30/min, audit
- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
> Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe).
> **Doc** : [`docs/V70_Automations_Workers.md`](docs/V70_Automations_Workers.md)
- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
- [x] **Compat legacy** — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
> **Doc** : [`docs/V71_Calendar_Meetings.md`](docs/V71_Calendar_Meetings.md)
- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
- [x] **Providers sans dépendance** — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
- [x] **Tests** — `tests/test_v71_calendar_meetings.py` (**15 tests** : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + `meeting.summarized` → automation, validations audio, 404)
- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https://<domain>/.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
- [x] **Tests** — `tests/test_v72_enterprise.py` (**52 tests** : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
- [x] **Teamspaces** — `teamspaces` (`workspace_id`, `name`, `private`) + `teamspace_members` (rôles owner/editor/commenter/viewer) ; `pages.teamspace_id` + `collections.teamspace_id` ; `private` → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `<pre class="mermaid">` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
- [x] **Collab polish** — `comment_reactions` (agrégation emoji + toggle), `page_follows` (toggle + followers), `guest_shares` → `GET /g/{token>` sans compte (vue enregistrée, 404 HTML si expiré/révoqué), `page_views` (compteurs journaliers, séries sans trou)
- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note |
@@ -963,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
@@ -996,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.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.*
*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
---
## 🔴 Anomalies & dette technique — audit complet 2026-09-30
> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
### 🔴 P0 — Critique (avant toute exposition réseau)
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
### 🟠 P1 — Hautes
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
- [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**.*
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Reste** : `unsafe-eval` (Alpine x-data en string → build `@alpinejs/csp`), externalisation JS (A27), resserrer `img-src`/`connect-src`, vendoriser les 2 CDN. Effort : **L**.
- [ ] **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**.*
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
### 🟢 P3 — Basses / hygiène
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
- [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
- [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [x] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé.* — **fait 2026-10-01 (partiel)** : les 29 `Environment(...)` = A10 ✓ ; les 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` → `settings.data_dir` (property, lecture à chaque accès car les tests monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque écriture. **Reste** : le client httpx partagé (52 créations — un `AsyncClient` module-level est fragile avec les event loops de tests, à faire avec un cache par loop). Effort : **M**.
- [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker)
`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. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0.
+1 -1
View File
@@ -1 +1 @@
6.7.0
7.7.0
+11 -2
View File
@@ -1,7 +1,7 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0)
> **Début**: 2026-07-08 | **Version**: v7.7.0 (audit — A20 partiel : nonce CSP) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
@@ -28,6 +28,15 @@
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
| v6.8.0 | Sites multi-pages + Forms publics ([doc](docs/V68_Sites_Forms.md)) | ✅ | 20 |
| v6.9.0 | Recherche hybride + Ask AI RAG ([doc](docs/V69_Search_Ask_AI.md)) | ✅ | 24 |
| v7.0.0 | Automations multi-étapes + Workers lite ([doc](docs/V70_Automations_Workers.md)) | ✅ | 31 |
| v7.1.0 | Calendar sync + Meeting Notes ([doc](docs/V71_Calendar_Meetings.md)) | ✅ | 15 |
| v7.2.0 | SCIM + 2FA + Audit UI + gouvernance agents ([doc](docs/V72_Enterprise_SCIM_2FA.md)) | ✅ | 52 |
| v7.3.0 | Teamspaces + Verified + Polish + follow-ups ([doc](docs/V73_Wiki_Teamspaces_Polish.md)) | ✅ | 72 |
> **Suites de régression** : `test_v72_enterprise.py` (52) + `test_v73_wiki_polish.py` (72) = **124 verts** · suite complète `-n auto` = **1016 passed**.
> **Follow-ups v7.3 livrés** (voir § v7.3.0 du `ROADMAP.md`): sidebar par teamspace, charts `number`/multi-DB, notif `page.updated` aux followers, unfurl `gitea:`/`github:`, page Settings → Audit — + 21 casses SSO corrigées (install `python3-saml`/`authlib`).
## Blocs Complétés
-2
View File
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import UTC, datetime
from uuid import uuid4
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
@@ -31,7 +31,7 @@ class SessionManager:
"""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -94,7 +94,7 @@ class SessionManager:
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
)
conn.commit()
except Exception:
pass
logger.exception("_touch_session")
# FastAPI dependency
+13 -4
View File
@@ -1,12 +1,22 @@
"""FlowDeck — Configuration via pydantic-settings."""
from __future__ import annotations
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@property
def data_dir(self) -> str:
"""Racine des fichiers (avatars, uploads…).
Pas un champ : la lecture est faite à chaque accès parce que les tests
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
"""
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
@@ -22,9 +32,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 = ""
@@ -129,7 +136,9 @@ class Settings(BaseSettings):
import re
if re.match(r'^[a-zA-Z]:', p):
return Path(p)
return Path("/" + p)
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
return Path("/" + p.lstrip("/"))
return Path("/data/flowdeck.db")
+5
View File
@@ -837,6 +837,11 @@ def get_conn():
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
# l'event loop) reste à migrer module par module.
conn.execute("PRAGMA busy_timeout=5000")
try:
yield conn
finally:
+120 -19
View File
@@ -40,19 +40,28 @@ from app.routers import (
)
from app.routers.api_v2 import router as api_v2_router
from app.routers.api_v2_agent import router as api_v2_agent_router
from app.routers.audit import router as audit_router
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_router
from app.routers.scim import router as scim_router
from app.routers.search_ai import router as search_ai_router
from app.routers.sites import router as sites_router
from app.routers.sso import router as sso_router
from app.routers.web_clipper import api_router as web_clipper_api_router
from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -62,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:
@@ -124,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.7.0",
version="7.7.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -134,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
app.add_middleware(CSRFMiddleware)
app.add_middleware(ContentSecurityPolicyMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# A37 : origines explicites (l'auth est un cookie de session ; le front est
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
allow_credentials=True,
)
app.include_router(auth.router)
app.include_router(sso_router)
@@ -173,6 +249,17 @@ app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
app.include_router(search_ai_router)
app.include_router(workers_router)
app.include_router(meetings_router)
# v7.2.0 — enterprise admin
app.include_router(scim_router)
app.include_router(webauthn_router)
app.include_router(audit_router)
app.include_router(governance_router)
# v7.3.0 — teamspaces + verified wiki
app.include_router(wiki_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -262,17 +349,31 @@ async def http_exception_handler(request: Request, exc: _StarHTTPException):
"""
status = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
is_api_v2 = request.url.path.startswith("/api/v2")
# Programmatic API prefixes that must always answer JSON errors instead of
# being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch).
JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn")
is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES)
if status == 404:
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
if "/api" in request.url.path:
if is_json_api and request.url.path.startswith("/scim/v2"):
from fastapi.responses import JSONResponse
return JSONResponse(
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": detail if isinstance(detail, str) else "Not found",
"status": "404"},
status_code=404,
headers={"Content-Type": "application/scim+json"},
)
if "/api" in request.url.path or is_json_api:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
# Non-404: RFC7807 for /api/v2
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
from fastapi.responses import JSONResponse
+17 -1
View File
@@ -16,7 +16,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/saml", "/auth/oidc", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
# library, gitea_workspace, workspace, workspaces, welcome).
# Ne restent que du machine-to-machine / hors session :
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
# - publics : /s/ (sites), /f/ (forms)
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
EXCLUDED_PATHS = {
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+78 -9
View File
@@ -1,6 +1,8 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations
import ipaddress
import secrets
import time
from collections import defaultdict
@@ -8,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
@@ -62,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""
CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
# chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; "
@@ -77,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
)
async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response
@@ -97,8 +117,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 +134,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 +152,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
+508 -22
View File
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
)
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
unitaire se lit déjà dans le set.
"""
if not table.replace("_", "").isalnum():
raise ValueError(f"nom de table invalide: {table!r}")
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
_apply_one(conn, version, name, fn)
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
"""A31 : une migration = une transaction (DDL tout-ou-rien).
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
rollback complet à l'échec, donc la prochaine exécution retente proprement.
"""
if conn.in_transaction:
# transaction résiduelle du caller (init_db commit juste avant) — on
# part d'un état propre plutôt que d'englober son travail.
conn.commit()
conn.execute("BEGIN")
try:
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
except BaseException:
conn.rollback()
raise
# ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_pcols = columns(conn, "api_tokens")
if "id" not in _pcols:
conn.execute(
"""
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
_scols = columns(conn, "user_sessions")
if "id" not in _scols:
conn.execute(
"""
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
_projcols = columns(conn, "projects")
if "id" not in _projcols:
conn.execute(
"""
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
_cols = columns(conn, "database_templates")
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
_vcols = columns(conn, "collection_views")
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
_cpcols = columns(conn, "collection_pages")
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
_ucols = columns(conn, "users")
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
_ccols = columns(conn, "collections")
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
``idempotency_keys`` — Idempotency-Key support for POST creations.
"""
# api_tokens extra columns
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_cols = columns(conn, "api_tokens")
if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols:
@@ -862,7 +895,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,
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt).
"""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
_cols = columns(conn, "webhook_deliveries")
if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols:
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -985,6 +1018,459 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
)
@register(24, "v6.8.0: Sites & public Forms")
def _migration_sites_forms(conn: sqlite3.Connection) -> None:
"""v6.8.0 — Notion Sites + Forms publics (voir docs/V68_Sites_Forms.md).
``sites`` — mini-site multi-pages (slug, root_page, thème,
domaine custom, password hash, expiry, noindex).
``site_pages`` — arbre public ordonné (site_id, page_id, position).
``site_views`` — compteur de vues jour/site (upsert, pas d'IP brute).
``form_responses`` — log des soumissions anonymes (ip_hash jour, pas d'IP).
``collections.form_config_json`` — config du formulaire public par DB.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'dark',
custom_domain TEXT UNIQUE,
password_hash TEXT DEFAULT '',
expires_at TIMESTAMP,
noindex INTEGER NOT NULL DEFAULT 0,
analytics_id TEXT DEFAULT '',
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE(site_id, page_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_site_pages_site ON site_pages(site_id, position)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_views (
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, day)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS form_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
row_id INTEGER REFERENCES collection_pages(id) ON DELETE SET NULL,
ip_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = columns(conn, "collections")
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 = columns(conn, "pages")
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 = columns(conn, "automations")
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = columns(conn, "collection_properties")
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 = columns(conn, "collection_pages")
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 = columns(conn, "users")
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
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 = columns(conn, table)
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.
+1 -18
View File
@@ -1,10 +1,9 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
# ── File Save ────────────────────────────────────────────────
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
error: str | None = None
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
+44 -24
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall()
now = datetime.utcnow()
now = datetime.now(UTC).replace(tzinfo=None)
for trig in triggers:
last = trig["last_fired_at"]
if last:
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int | None:
async def _current_user_id(request: Request) -> int:
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
user = await get_current_user(request)
if user and user.get("id"):
return user["id"]
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else None
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
async def _workspace_id(request: Request) -> int | None:
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
async def _current_admin(request: Request) -> dict:
"""Require an admin session. Falls back to the single admin row, matching
the agent router's unauthenticated convention (single-user deployments)."""
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
`ping()` vers un `api_base` de son choix = SSRF)."""
user = await get_current_user(request)
if user:
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return dict(row)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
def _default_agent(conn, user_id: int) -> dict:
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
return {"ok": False, "provider": provider, "error": str(exc)}
def _check_api_base(value: str) -> str:
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
settings `llm_allow_private=false`.
"""
url = (value or "").strip()
if not url:
return ""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
return url
@router.patch("/providers")
async def update_provider_config(request: Request):
await _current_admin(request)
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
provider=provider or None,
model=(body.get("model") or "").strip() or None,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
clear_keys=(provider == "offline"),
)
llm = LLMClient()
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
llm = LLMClient(
provider=provider,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
)
try:
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
+34 -25
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
async def _require_session_or_bearer(request: Request) -> None:
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
if request.url.path in _API_PUBLIC_PATHS:
return
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
return
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
from app.routers.public_api import verify_token
verify_token(auth)
return
raise HTTPException(401, "Authentication required")
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
# ── Simple rate limiter (in-memory, per-IP) ──
_rate_limit_store: dict[str, tuple[float, int]] = {}
@@ -26,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
now = datetime.utcnow().timestamp()
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60:
_rate_limit_store[ip] = (now, 1)
@@ -47,12 +67,12 @@ async def health(request: Request):
conn.execute("SELECT 1")
db_ok = True
except Exception:
pass
logger.exception("health")
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
pass
logger.exception("health")
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
@@ -79,22 +99,6 @@ async def stats():
}
@router.get("/projects")
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
"""List Gitea projects (JSON)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception:
repos = []
return {"projects": repos}
@router.post("/move")
async def move_card(
request: Request,
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
"comments": comments,
"checklists": checklists,
}
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("card_detail.html")
return HTMLResponse(template.render(**ctx))
@@ -547,8 +551,13 @@ async def get_my_profile(request: Request):
if not user:
return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn:
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
# password_hash, login_attempts et locked_until.
row = conn.execute(
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, last_login, created_at "
"FROM users WHERE login=?",
(user.get("login", ""),),
).fetchone()
if row:
return dict(row)
+112 -265
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
"""FlowDeck — unified audit log API (v7.2.0).
Merges ``api_audit_log`` + ``permission_audit_log`` + ``sso_login_history``
with actor/resource/date filters and CSV export (10k rows max, 365-day
retention note). Admin only. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.api_v2_helpers import (
has_scope,
parse_pagination,
resolve_bearer_token,
)
router = APIRouter(tags=["audit"])
def _admin_user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
(sess.get("id"),)).fetchone()
if row and row["is_admin"]:
return sess
raise HTTPException(403, "Admin required")
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if user and user.get("is_admin") and has_scope(
user.get("_token_scopes") or "read", "admin"):
return user
raise HTTPException(401, "Admin authentication required")
def _query(source: str, actor: str, action: str, limit: int, offset: int):
"""One source query → (rows, columns). All normalized to a common shape."""
with get_conn() as conn:
if source in ("api", "all"):
rows = conn.execute(
"""SELECT created_at AS at, user_id AS actor, action,
resource_type || ':' || resource_id AS resource,
ip_address AS ip, detail, 'api' AS source
FROM api_audit_log
WHERE (?='' OR CAST(user_id AS TEXT)=?)
AND (?='' OR action LIKE ?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
).fetchall()
if source == "api":
return rows
api = [dict(r) for r in rows]
else:
api = []
if source in ("permissions", "all"):
rows = conn.execute(
"""SELECT created_at AS at, performed_by AS actor, action,
resource_type || ':' || resource_id AS resource,
ip_address AS ip,
('target=' || COALESCE(target_user_id, target_group_id, '')
|| ' ' || COALESCE(old_role,'') || '→' || COALESCE(new_role,'')) AS detail,
'permissions' AS source
FROM permission_audit_log
WHERE (?='' OR CAST(performed_by AS TEXT)=?)
AND (?='' OR action LIKE ?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
).fetchall()
if source == "permissions":
return rows
perm = [dict(r) for r in rows]
else:
perm = []
if source in ("sso", "all"):
rows = conn.execute(
"""SELECT created_at AS at, user_id AS actor,
('sso_' || provider_type || '_' ||
CASE success WHEN 1 THEN 'success' ELSE 'failure' END) AS action,
provider_name AS resource, ip_address AS ip,
COALESCE(error_message, sso_identifier, '') AS detail,
'sso' AS source
FROM sso_login_history
WHERE (?='' OR CAST(user_id AS TEXT)=?)
ORDER BY id DESC LIMIT ? OFFSET ?""",
(actor, actor, limit, offset)).fetchall()
if source == "sso":
return rows
sso = [dict(r) for r in rows]
else:
sso = []
merged = sorted(api + perm + sso, key=lambda d: str(d.get("at") or ""),
reverse=True)
return merged[:limit]
@router.get("/api/v2/audit/logs")
async def audit_logs(request: Request):
_admin_user(request)
qp = request.query_params
source = (qp.get("source") or "all").lower()
if source not in ("all", "api", "permissions", "sso"):
raise HTTPException(400, "source must be all|api|permissions|sso")
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
if qp.get("format") == "csv":
import csv
import io
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
"resource", "ip", "detail"])
writer.writeheader()
for r in rows[:10000]:
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition":
"attachment; filename=audit.csv"})
return JSONResponse(content={"logs": rows, "source": source,
"limit": limit, "offset": offset})
+115 -4
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -148,7 +158,7 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register")
async def register_page(request: Request):
"""Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"'
).replace(
@@ -163,7 +173,7 @@ async def register_page(request: Request):
).replace(
'id="submit-btn">Login<',
'id="submit-btn">Register<'
), status_code=200)
)), status_code=200)
@router.get("/login")
@@ -172,7 +182,7 @@ async def login(request: Request, provider: str = Query("gitea")):
# Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse
if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured
from app.auth.providers import get_provider
@@ -318,12 +328,32 @@ async def local_login(request: Request):
status_code=403,
)
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
if not ud.get("is_admin"):
with get_conn() as conn:
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
if dom:
enforced = conn.execute(
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
" AND enforce_sso=1", (dom,)).fetchone()
if enforced:
return JSONResponse(
{"error": "Local login is disabled for your domain — sign in with SSO"},
status_code=403)
with get_conn() as conn:
conn.execute(
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
(str(time.time()), ud["id"]),
)
conn.commit()
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
from app.services import two_factor as _2fa
if _2fa.is_enabled(ud["id"]):
return JSONResponse({"status": "2fa_required",
"pending": _2fa.mint_pending(ud["id"])})
session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
@@ -458,6 +488,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."""
@@ -472,4 +583,4 @@ def _log_login(user_id: int, request: Request):
)
conn.commit()
except Exception:
pass
logger.exception("_log_login")
+153 -4
View File
@@ -4,14 +4,28 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import get_page_context, run_automation
from app.services.automations import (
get_page_context,
get_steps,
press_button,
run_automation,
validate_step,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
def _require_session(request: Request) -> None:
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(status_code=401, detail="Authentication required")
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
TRIGGER_TYPES = ("event", "cron", "button")
@@ -69,7 +83,7 @@ async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
user = _current_user(request)
by = user.get("id") or 1
by = user["id"]
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO automations
@@ -172,3 +186,138 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
STEP_SECRET_FIELDS = {"webhook_url"}
def _require_session(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
def _get_auto(auto_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
return dict(row) if row else None
def _auto_404():
# NOTE: return (not raise) — the global 404 handler redirects non-/api
# paths to /workspaces, which TestClient follows into a 200.
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Automation not found"}, status_code=404)
def _encrypt_step_config(config: dict) -> dict:
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
from app.services.sso_provisioning import encrypt_secret
cfg = dict(config or {})
for field in STEP_SECRET_FIELDS:
if field in cfg and cfg[field]:
val = str(cfg[field])
if not val.startswith("gAAAAA"):
cfg[field] = encrypt_secret(val)
return cfg
@router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None:
return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@router.post("/workspace/automations/{auto_id}/steps")
async def create_step(request: Request, auto_id: int):
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
kind = body.get("kind", "")
config = body.get("config", {}) or {}
validate_step(kind, config)
with get_conn() as conn:
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
(auto_id,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
" VALUES (?,?,?,?)",
(auto_id, kind, int(body.get("position", pos)),
json.dumps(_encrypt_step_config(config))))
conn.commit()
step_id = cur.lastrowid
return {"id": step_id, "status": "created"}
@router.put("/workspace/automations/steps/{step_id}")
async def update_step(request: Request, step_id: int):
_require_session(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
if not row:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Step not found"}, status_code=404)
kind = body.get("kind", row["kind"])
try:
config = body.get("config", json.loads(row["config_json"] or "{}"))
except (TypeError, json.JSONDecodeError):
config = {}
validate_step(kind, config if isinstance(config, dict) else {})
conn.execute(
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
(kind, int(body.get("position", row["position"])),
json.dumps(_encrypt_step_config(config)), step_id))
conn.commit()
return {"id": step_id, "status": "updated"}
@router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int):
_require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
conn.commit()
return {"id": step_id, "status": "deleted"}
@router.put("/workspace/automations/{auto_id}/mode")
async def set_trigger_mode(request: Request, auto_id: int):
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
_require_session(request)
if _get_auto(auto_id) is None:
return _auto_404()
body = await request.json() if request.headers.get("content-type") else {}
mode = (body.get("mode") or "any").lower()
if mode not in ("any", "all"):
raise HTTPException(status_code=400, detail="mode must be any or all")
with get_conn() as conn:
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
conn.commit()
return {"id": auto_id, "trigger_mode": mode}
@router.post("/api/automations/press-button")
async def press_button_endpoint(request: Request):
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
body = await request.json() if request.headers.get("content-type") else {}
try:
collection_id = int(body.get("collection_id", 0))
row_id = int(body.get("row_id", 0))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
prop_ref = body.get("property", body.get("property_id", ""))
if not prop_ref:
raise HTTPException(status_code=400, detail="property required")
user = _current_user(request)
try:
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
return result
+108 -67
View File
@@ -3,18 +3,21 @@ from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -694,7 +697,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 +783,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 +806,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 = ""):
@@ -849,15 +852,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
return [dict(r) for r in rows]
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
"""Return groups from Gitea labels/milestones or fallback to defaults."""
try:
labels = json.loads(
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
) if False else []
except Exception:
labels = []
return labels if labels else ["Design", "Engineering", "No Team"]
async def asyncio_get_labels(owner: str, repo: str):
@@ -907,8 +901,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 +975,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 +990,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 ═══════════
@@ -1019,35 +1013,20 @@ async def update_share(request: Request, page_id: int):
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
import secrets
slug = "p-" + secrets.token_urlsafe(8)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
(slug, page_id),
)
conn.commit()
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
slug, title = publish(page_id)
await fire_published(page_id, slug)
return {"is_published": True, "publish_slug": slug, "title": title}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
unpublish(page_id)
await fire_unpublished(page_id)
return {"is_published": False}
@@ -1068,7 +1047,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 +1062,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 +1191,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 +1218,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 +1363,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 +1398,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 +1438,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 +1505,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}
@@ -1689,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _ws_id_for(request: Request, page_id: int) -> int:
@@ -1726,7 +1709,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 +1866,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 +1878,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 +2017,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 +2028,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 +2122,15 @@ async def sync_project(owner: str, repo: str):
if board:
board_id = board["id"]
columns = json.loads(board["columns_json"])
# A23 : un seul executemany pour toutes les cards.
conn.executemany(
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
[
(board_id, issue["number"], _issue_column(issue, columns, board_id))
for issue in issues_only
],
)
for issue in issues_only:
col = _issue_column(issue, columns, board_id)
conn.execute(
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
(board_id, issue["number"], col),
)
# Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit()
+9 -3
View File
@@ -107,6 +107,12 @@ async def add_comment(request: Request, page_id: int):
comment_id = cur.lastrowid
conn.commit()
# v7.3.0: commenting implies following — the author gets the
# (throttled) page.updated notifications like any other follower.
from app.services import wiki as wiki_svc
wiki_svc.ensure_follow(page_id, uid, conn=conn)
conn.commit()
# Notify users @-mentioned in the comment (skip the author).
url = _page_url(page_id)
title = f"New comment on “{page['title']}”"
@@ -123,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
except Exception:
pass
logger.exception("add_comment")
return {"id": comment_id, "status": "created"}
@@ -153,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
except Exception:
pass
logger.exception("notify_page_mentions")
return {"mentioned": mentioned}
@@ -184,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"id": comment_id, "status": "updated"}
+189 -29
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
from __future__ import annotations
import html as _htmlmod
import json
import logging
import sqlite3
@@ -26,6 +27,7 @@ from app.services.recurrence import (
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict:
@@ -42,23 +44,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 +374,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 +1914,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 +1993,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 +2274,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>
@@ -2217,13 +2377,13 @@ canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
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 +2391,7 @@ new Chart(document.getElementById('chartCanvas'), {{
options: {{ responsive: true }}
}});
</script>
<p class="desc">{len(pages)} entries</p>
<p class="desc">{subtitle}</p>
""")
@@ -2280,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
@@ -2331,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
+130 -69
View File
@@ -2,11 +2,13 @@
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
from app.config import settings
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
@@ -52,7 +54,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 +90,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 +181,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 +195,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 +208,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 +254,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 +284,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 +312,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,12 +436,15 @@ 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:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
users = conn.execute(
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
).fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@@ -451,8 +453,8 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
@@ -656,8 +658,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -689,11 +691,20 @@ def _get_user_id(request: Request) -> int:
return user["id"] if user and user.get("id") else 1
def _require_user_id(request: Request) -> int:
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user["id"]
@router.put("/api/user/profile")
async def update_profile(request: Request):
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit()
@@ -702,13 +713,18 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
from app.password_utils import hash_password
from app.password_utils import hash_password, verify_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
uid = _get_user_id(request)
uid = _require_user_id(request)
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
current = body.get("current_password", "")
with get_conn() as conn:
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
if not row or not verify_password(current, row["password_hash"]):
raise HTTPException(403, "Current password is incorrect")
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit()
return {"status": "ok"}
@@ -717,7 +733,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
import secrets
uid = _get_user_id(request)
uid = _require_user_id(request)
token = secrets.token_hex(32)
with get_conn() as conn:
conn.execute(
@@ -730,7 +746,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
uid = _get_user_id(request)
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
@@ -755,14 +771,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()
@@ -777,7 +793,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
@@ -790,7 +806,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 ──
@@ -807,8 +823,8 @@ async def dashboard(
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(request=request, repos=repos, search=search,
@@ -820,8 +836,8 @@ async def dashboard(
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -839,8 +855,8 @@ async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -901,9 +917,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
@@ -922,7 +947,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": []}
@@ -1016,8 +1041,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:
@@ -1122,9 +1147,8 @@ def _file_page_disk_path(page: dict):
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
import os as _os
from pathlib import Path
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
root = Path(settings.data_dir).resolve()
full = (root / rel).resolve()
try:
full.relative_to(root)
@@ -1138,9 +1162,21 @@ def _file_page_disk_path(page: dict):
return (full, filename, mime, size)
def _require_page_view(request: Request, page_id: int) -> None:
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
from app.services.permission_manager import PermissionManager
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
@router.get("/api/pages/{page_id}/download")
async def download_page_file(page_id: int):
async def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id)
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
@@ -1161,13 +1197,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 "
@@ -1451,7 +1489,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"}
@@ -1473,9 +1511,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
import os
from pathlib import Path
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
root = Path(settings.data_dir)
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1515,7 +1552,8 @@ async def upload_local_workspace_file(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1532,7 +1570,11 @@ 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}")
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1553,10 +1595,14 @@ async def upload_local_workspace_file(request: Request):
counter += 1
content = await f.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to("/data"))
rel_path = str(file_path.relative_to(data_root))
size = len(content)
mime = f.content_type or "application/octet-stream"
@@ -1588,7 +1634,8 @@ async def upload_local_workspace_folder(request: Request):
import json
from pathlib import Path
ws = _get_active_workspace(request, user_id=_get_user_id(request))
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
@@ -1610,7 +1657,11 @@ 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}")
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
@@ -1669,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', ?)""",
@@ -1721,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:
@@ -1824,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:
@@ -1873,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)
@@ -2036,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"]}}
@@ -2142,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:
@@ -2180,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) }}"
@@ -2201,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(
@@ -2221,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 = ""
+3 -3
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
router = APIRouter(tags=["emojis"])
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _active_ws(request: Request) -> int:
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
+15 -5
View File
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["export"], prefix="/api/export")
def _load_page_or_404(page_id: int) -> dict:
def _load_page_or_404(request: Request, page_id: int) -> dict:
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
doit pas délivrer le contenu d'une page énumérable par id."""
from app.auth.session import SessionManager
from app.services.permission_manager import PermissionManager
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(status_code=404, detail="Page not found")
return dict(row)
@@ -52,7 +62,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
headers = _download_header(filename, "text/markdown")
@@ -61,7 +71,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
headers = _download_header(filename, "text/html")
@@ -70,7 +80,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
except ImportError:
@@ -85,7 +95,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
page = _load_page_or_404(page_id)
page = _load_page_or_404(request, page_id)
site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
filename = f"{title}_site.zip"
-8
View File
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
+98
View File
@@ -0,0 +1,98 @@
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
from __future__ import annotations
import json
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import agent_policies as policies
from app.services.api_v2_helpers import audit_log
router = APIRouter(tags=["governance"])
def _owner_or_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
is_admin = bool(row and row["is_admin"])
if not is_admin and request.query_params.get("workspace_id"):
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(request.query_params.get("workspace_id"), user["id"])).fetchone()
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(request.query_params.get("workspace_id"),
user["id"])).fetchone()
if not member and not owner:
raise HTTPException(403, "Workspace access required")
if member and member["role"] not in ("admin", "editor", "owner"):
raise HTTPException(403, "Editor role required")
user["is_admin"] = is_admin
return user
@router.get("/api/v2/agent-policies")
async def list_policies(request: Request):
_owner_or_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
return {"policies": [dict(r) for r in rows]}
@router.post("/api/v2/agent-policies")
async def upsert_policy(request: Request):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
wid = body.get("workspace_id")
tools = body.get("allowed_tools")
if tools is not None and not isinstance(tools, list):
raise HTTPException(400, "allowed_tools must be a list or null")
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
require_approval)
VALUES (?,?,?,?)
ON CONFLICT(workspace_id) DO UPDATE SET
allowed_tools_json=excluded.allowed_tools_json,
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
(wid, json.dumps(tools) if tools is not None else None,
max(1, min(int(body.get("max_steps") or 12), 50)),
1 if body.get("require_approval") else 0))
conn.commit()
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
(wid,)).fetchone()
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
return JSONResponse(status_code=201, content=dict(row))
@router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
" LIMIT 100", (status,)).fetchall()
return {"approvals": [dict(r) for r in rows]}
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
async def decide_approval(approval_id: int, request: Request):
user = _owner_or_admin(request)
try:
body = await request.json()
except Exception:
body = {}
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
if out is None:
raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request)
return out
+2 -2
View File
@@ -44,9 +44,9 @@ async def import_page(request: Request):
user = _current_user(request)
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
from jinja2 import Environment, FileSystemLoader
from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
return HTMLResponse(content=env.get_template("import.html").render(user=user))
+206
View File
@@ -0,0 +1,206 @@
"""FlowDeck — Calendar links + Meetings API (v7.1.0).
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
transcript, AI summary (fires ``meeting.summarized``).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import calendar_sync as cal
from app.services import meetings as meet
from app.services.api_v2_helpers import (
audit_log,
has_scope,
resolve_bearer_token,
row_to_dict,
)
router = APIRouter(tags=["calendar-meetings"])
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
# ── calendar links ─────────────────────────────────────────────────────────
@router.post("/api/v2/calendar-links")
async def create_link(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
provider = (body.get("provider") or "").lower()
if provider not in cal.PROVIDERS:
raise HTTPException(400, "provider must be google|caldav")
try:
collection_id = int(body.get("collection_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "collection_id required") from None
creds = body.get("credentials") or {}
if provider == "google" and not creds.get("access_token"):
raise HTTPException(400, "google needs credentials.access_token")
if provider == "caldav" and not creds.get("url"):
raise HTTPException(400, "caldav needs credentials.url")
try:
out = cal.save_link(user["id"], provider, collection_id, creds,
body.get("calendar_id") or "primary",
body.get("date_property") or "")
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
return JSONResponse(status_code=201, content=out)
@router.get("/api/v2/calendar-links")
async def get_links(request: Request):
user = _auth_user(request)
return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}")
async def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
return {"status": "deleted", "id": link_id}
@router.post("/api/v2/calendar-links/{link_id}/sync")
async def sync_now(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Link not found")
try:
stats = await cal.sync_link(link_id)
except (cal.SyncError, ValueError) as exc:
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
str(exc)) from None
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
return {"link_id": link_id, **stats}
# ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request):
_auth_user(request)
qp = request.query_params
try:
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
qp.get("date_property", ""))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
return out
# ── meetings ───────────────────────────────────────────────────────────────
@router.post("/api/v2/meetings/transcribe")
async def upload_and_transcribe(request: Request):
user = _auth_user(request, require_write=True)
try:
form = await request.form()
except Exception:
raise HTTPException(400, "multipart upload required") from None
upload = form.get("audio")
try:
page_id = int(form.get("page_id", 0))
except (TypeError, ValueError):
raise HTTPException(400, "page_id required") from None
language = (form.get("language") or "fr")[:10]
manual = (form.get("transcript") or "").strip()
if upload is None and not manual:
raise HTTPException(400, "audio file or transcript required")
audio_path = ""
if upload is not None:
filename = (upload.filename or "").lower()
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
if ext not in meet.AUDIO_EXTENSIONS:
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
data = await upload.read()
if len(data) > meet.MAX_AUDIO_BYTES:
raise HTTPException(413, "audio exceeds 100 MB")
if not data:
raise HTTPException(400, "empty audio file")
audio_path = str(meet.meetings_dir()
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
with open(audio_path, "wb") as fh:
fh.write(data)
transcript = manual
if not transcript and audio_path:
try:
transcript = meet.transcribe_audio(audio_path, language)
except meet.TranscriptionUnavailable as exc:
transcript = "" # stored; client transcribes or posts manual text later
_ = exc
try:
tid = meet.save_transcript(page_id, transcript, language, audio_path)
except ValueError as exc:
raise HTTPException(404, str(exc)) from None
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
return JSONResponse(status_code=201, content={
**row_to_dict(row), "transcribed": bool(transcript)})
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
async def set_transcript_text(transcript_id: int, request: Request):
"""Store a client-side (manual) transcript on an existing row."""
_auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("transcript") or "").strip()
if not text:
raise HTTPException(400, "transcript required")
with get_conn() as conn:
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone():
raise HTTPException(404, "Transcript not found")
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
(text, transcript_id))
conn.commit()
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
return row_to_dict(row)
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
async def summarize(transcript_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
out = await meet.summarize_transcript(transcript_id, user.get("id"))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
except RuntimeError as exc:
raise HTTPException(502, str(exc)) from None
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
return out
+2 -2
View File
@@ -6,10 +6,10 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
from app.db import get_conn
from app.templating import ENV
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
</div>"""
from app.routers.dashboard import _sidebar_data
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
+4 -6
View File
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
).fetchone()
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
)
conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
+2 -2
View File
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
from app.templating import ENV
env = ENV
template = env.get_template("welcome.html")
return HTMLResponse(content=template.render(
user=user,
+8 -16
View File
@@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token.
When an authenticated session is present the token is bound to that user
(revocable from Settings → API tokens); otherwise a legacy shared token is
created for backward compatibility.
"""
"""Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
if user and user.get("id"):
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
else:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
+1 -1
View File
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
try:
await websocket.close(code=4401)
except Exception:
pass
logger.exception("ws_page")
return
conn = await manager.connect(websocket, page_id, user)
+297
View File
@@ -0,0 +1,297 @@
"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0).
``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and
deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and
revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification +
optional local-login enforcement per email domain.
See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import hashlib
import secrets
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.api_v2_helpers import audit_log
router = APIRouter(tags=["scim"])
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
# ── auth ───────────────────────────────────────────────────────────────────
def _scim_guard(request: Request) -> dict:
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
with get_conn() as conn:
row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0",
(digest,)).fetchone()
if row:
return {"scim_token_id": row["id"], "name": row["name"]}
raise HTTPException(401, "SCIM token required")
def _admin_session(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(403, "Admin required")
return user
def _scim_user(row) -> dict:
d = dict(row)
return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"],
"name": {"formatted": d.get("full_name") or d["login"]},
"emails": [{"value": d.get("email") or "", "primary": True}],
"active": bool(d.get("is_active", 1)),
"meta": {"resourceType": "User"}}
# ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users")
async def scim_list(request: Request):
_scim_guard(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
items = [_scim_user(r) for r in rows]
return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(items), "Resources": items}
@router.post("/scim/v2/Users")
async def scim_create(request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
username = (body.get("userName") or "").strip()
if not username:
raise HTTPException(400, "userName required")
email = ""
for em in body.get("emails") or []:
if isinstance(em, dict) and em.get("value"):
email = em["value"]
break
name = ((body.get("name") or {}).get("formatted") or username)[:200]
active = body.get("active", True)
with get_conn() as conn:
if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone():
raise HTTPException(409, "User already exists")
cur = conn.execute(
"INSERT INTO users (login, full_name, email, is_active, auth_method)"
" VALUES (?,?,?,?,'saml')",
(username, name, email, 1 if active else 0))
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
return JSONResponse(status_code=201, content=_scim_user(row))
@router.get("/scim/v2/Users/{user_id}")
async def scim_get(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "User not found")
return _scim_user(row)
def _apply_scim_update(conn, user_id: str, body: dict) -> None:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "User not found")
updates: dict = {}
if "userName" in body and body["userName"]:
updates["login"] = body["userName"].strip()
if isinstance(body.get("name"), dict) and body["name"].get("formatted"):
updates["full_name"] = body["name"]["formatted"][:200]
if isinstance(body.get("emails"), list):
for em in body["emails"]:
if isinstance(em, dict) and em.get("value"):
updates["email"] = em["value"][:200]
break
if "active" in body:
updates["is_active"] = 1 if body["active"] else 0
if updates:
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id))
if body.get("active") is False:
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
conn.commit()
@router.put("/scim/v2/Users/{user_id}")
async def scim_replace(user_id: str, request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
_apply_scim_update(conn, user_id, body)
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return _scim_user(row)
@router.patch("/scim/v2/Users/{user_id}")
async def scim_patch(user_id: str, request: Request):
_scim_guard(request)
try:
body = await request.json()
except Exception:
body = {}
flat: dict = {}
for op in body.get("Operations") or []:
path = (op.get("path") or "").lower()
if path in ("username", "active"):
flat["userName" if path == "username" else "active"] = op.get("value")
with get_conn() as conn:
_apply_scim_update(conn, user_id, {**body, **flat})
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
return _scim_user(row)
@router.delete("/scim/v2/Users/{user_id}")
async def scim_delete(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "User not found")
# Deprovision = suspend (keeps content + audit trail).
conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,))
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
conn.commit()
return JSONResponse(status_code=204, content=None)
# ── SCIM token management (admin, session) ─────────────────────────────────
@router.post("/api/v2/scim/tokens")
async def create_scim_token(request: Request):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
raw = f"scim_{secrets.token_urlsafe(32)}"
digest = hashlib.sha256(raw.encode()).hexdigest()
with get_conn() as conn:
cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)"
" VALUES (?,?,?)",
(digest, str(body.get("name") or "SCIM")[:120], admin["id"]))
conn.commit()
audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request)
return JSONResponse(status_code=201,
content={"id": cur.lastrowid, "token": raw,
"warning": "shown once"})
@router.get("/api/v2/scim/tokens")
async def list_scim_tokens(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
" FROM scim_tokens ORDER BY id DESC").fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.delete("/api/v2/scim/tokens/{token_id}")
async def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
conn.commit()
audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request)
return {"status": "revoked", "id": token_id}
# ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims")
async def list_domains(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
out = []
for r in rows:
d = dict(r)
d.pop("txt_token", None)
out.append(d)
return {"domains": out}
@router.post("/api/v2/domain-claims")
async def create_domain(request: Request):
admin = _admin_session(request)
try:
body = await request.json()
except Exception:
body = {}
domain = (body.get("domain") or "").strip().lower()
if not domain or "." not in domain or "/" in domain:
raise HTTPException(400, "valid domain required")
token = f"flowdeck-verify={secrets.token_hex(16)}"
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO domain_claims
(domain, txt_token, auto_join_role, enforce_sso, workspace_id)
VALUES (?,?,?,?,?)""",
(domain, token, body.get("auto_join_role") or "viewer",
1 if body.get("enforce_sso") else 0, body.get("workspace_id")))
conn.commit()
except Exception:
raise HTTPException(409, "Domain already claimed") from None
did = cur.lastrowid
audit_log(admin, "domain.claim", "domain", did, domain, request)
return JSONResponse(status_code=201, content={
"id": did, "domain": domain,
"verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt",
"expected_content": token})
@router.post("/api/v2/domain-claims/{domain_id}/verify")
async def verify_domain(domain_id: int, request: Request):
admin = _admin_session(request)
import httpx
with get_conn() as conn:
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
if not row:
raise HTTPException(404, "Domain not found")
claim = dict(row)
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url)
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
except Exception: # noqa: BLE001 — unreachable domain = not verified
ok = False
if ok:
with get_conn() as conn:
conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,))
conn.commit()
audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request)
return {"id": domain_id, "verified": ok}
@router.delete("/api/v2/domain-claims/{domain_id}")
async def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
conn.commit()
audit_log(admin, "domain.delete", "domain", domain_id, "", request)
return {"status": "deleted", "id": domain_id}
+95
View File
@@ -0,0 +1,95 @@
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
(LLM when configured, extractive offline fallback), cached 10 min.
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
See ``docs/V69_Search_Ask_AI.md``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import semantic_search as sem
from app.services.api_v2_helpers import (
audit_log,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
)
router = APIRouter(tags=["search-ai"])
def _auth_user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if not has_scope(user.get("_token_scopes") or "read", "read"):
raise HTTPException(403, "Insufficient scope. Required: read")
return user
raise HTTPException(401, "Authentication required")
@router.get("/api/v2/search/hybrid")
async def hybrid(request: Request):
user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q:
raise HTTPException(400, "q is required")
limit, offset = parse_pagination(request)
ws_raw = request.query_params.get("workspace_id")
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
workspace_id=workspace_id)
page = results[offset:offset + limit]
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
# 5 min). Best-effort is handled by tests calling index_resource directly.
resp = JSONResponse({"query": q, "results": page,
"total": len(results), "limit": limit, "offset": offset})
for k, v in paginate_headers(len(results)).items():
resp.headers[k] = v
return resp
@router.post("/api/v2/search/ask")
async def ask_ai(request: Request):
user = _auth_user(request)
try:
body = await request.json()
except Exception:
body = {}
question = (body.get("question") or body.get("q") or "").strip()
if not question:
raise HTTPException(400, "question is required")
ws = body.get("workspace_id")
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
out = await sem.ask(question, user, workspace_id)
audit_log(user, "search.ask", "search", "", question[:200], request)
return {"question": question, "workspace_id": workspace_id, **out}
@router.get("/api/v2/search/index-status")
async def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request)
with get_conn() as conn:
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
pages_total = conn.execute(
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
return {"indexed_resources": indexed, "vectors": vectors,
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
"user_id": user.get("id")}
+1 -1
View File
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
try:
request.session.clear()
except Exception:
pass
logger.exception("revoke_session")
return {"status": "revoked"}
+9 -62
View File
@@ -2,15 +2,14 @@
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
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event as _fire_event
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
return user
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ──
@@ -130,7 +121,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,
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
slug, _title = publish(page_id)
await fire_published(page_id, slug)
return {
"page_id": page_id,
"is_published": True,
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
unpublish(page_id)
await fire_unpublished(page_id)
return {
"page_id": page_id,
"is_published": False,
@@ -399,7 +346,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 +354,5 @@ async def track_recent(request: Request):
"status": "tracked",
"user_id": user["id"],
"page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(),
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
+4 -3
View File
@@ -27,9 +27,10 @@ DEFAULT_CONFIG = {
"recents": {"visible": True, "order": 3, "show_count": 10},
"favorites": {"visible": True, "order": 4, "show_count": 10},
"agents": {"visible": True, "order": 5, "show_count": None},
"shared": {"visible": True, "order": 6, "show_count": 10},
"published": {"visible": True, "order": 7, "show_count": 10},
"private": {"visible": True, "order": 8, "show_count": None},
"teamspaces": {"visible": True, "order": 6, "show_count": None},
"shared": {"visible": True, "order": 7, "show_count": 10},
"published": {"visible": True, "order": 8, "show_count": 10},
"private": {"visible": True, "order": 9, "show_count": None},
}
+838
View File
@@ -0,0 +1,838 @@
"""FlowDeck — Sites & public Forms (v6.8.0).
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
password/expiry gating, SEO + view stats, and anonymous collection forms
(/f/<token>) with rate limiting, validation and notifications.
Auth: session cookie first, Bearer token fallback (api_tokens,
extension_devices, legacy user_tokens) via api_v2_helpers.
"""
from __future__ import annotations
import hashlib
import html
import json
import logging
import re
import secrets
import time
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.password_utils import hash_password, verify_password
from app.services.api_v2_helpers import (
audit_log,
get_bearer_user,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
row_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sites"])
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$")
# In-memory rate limiting for anonymous form posts: ip -> (window_start, count).
_form_rate: dict[str, tuple[float, int]] = {}
_FORM_RATE_MAX = 20
_FORM_RATE_WINDOW = 3600.0
# ── helpers ────────────────────────────────────────────────────────────────
def _slugify(title: str) -> str:
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
def _check_slug(slug: str) -> None:
if not _SLUG_RE.match(slug or ""):
raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.")
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
"""Session-first auth, Bearer fallback. Enforces scope for Bearer tokens."""
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
def _site_auth_cookie(site_id: int) -> str:
return f"site_auth_{site_id}"
def _site_unlocked(request: Request, site: dict) -> bool:
if not site.get("password_hash"):
return True
from itsdangerous import BadSignature, URLSafeTimedSerializer
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
try:
val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400)
return val == site["id"]
except BadSignature:
return False
except Exception:
return False
def _site_expired(site: dict) -> bool:
exp = site.get("expires_at")
if not exp:
return False
try:
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp() < time.time()
except Exception:
return False
def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None:
row = None
if slug:
row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone()
elif host:
row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone()
return dict(row) if row else None
def _site_pages(conn, site_id: int) -> list[dict]:
rows = conn.execute(
"""SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position
FROM site_pages sp JOIN pages p ON p.id = sp.page_id
WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='')
ORDER BY sp.position, p.id""",
(site_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}"
out.append(d)
return out
def _find_site_page(pages: list[dict], ref: str) -> dict | None:
ref = (ref or "").strip()
if ref.isdigit():
for p in pages:
if p["id"] == int(ref):
return p
for p in pages:
if p["slug"] == ref:
return p
# slug with -<id> suffix fallback
m = re.search(r"-(\d+)$", ref)
if m:
for p in pages:
if p["id"] == int(m.group(1)):
return p
return None
def _render_page_html(page: dict) -> str:
"""Render a pages row to HTML (blocks → public renderer, else <pre>)."""
if page.get("content_format") == "blocks" and page.get("content"):
try:
from app.routers import dashboard as _dash
blocks = json.loads(page["content"])
try:
from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks)
except Exception:
logger.exception("_render_page_html")
titles: dict = {}
try:
from app.db import get_conn as _gc
from app.services.wiki_links import token_labels
with _gc() as _c:
titles = token_labels(_c, page["content"])
except Exception:
titles = {}
return _dash._render_blocks_public(blocks, titles)
except Exception:
return f"<p>{html.escape(str(page.get('content', '')))}</p>"
if page.get("content"):
return (
"<pre style='white-space:pre-wrap;font-family:system-ui;"
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
)
return "<p style='color:#999'>Empty page.</p>"
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
body_html: str, noindex: bool = False) -> str:
nav = "".join(
f"<a href='/s/{site['slug']}/{p['slug']}'"
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
for p in pages
)
robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow"
desc = html.escape((site.get("title") or title)[:160])
theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff"
theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222"
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="robots" content="{robots}">
<meta name="description" content="{desc}">
<meta property="og:title" content="{html.escape(title)}">
<meta property="og:description" content="{desc}">
<meta name="twitter:card" content="summary">
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
<body><div class="layout"><nav class="nav">
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
<main class="main">{body_html}</main></div></body></html>"""
def _track_view(site_id: int) -> None:
day = datetime.now(UTC).strftime("%Y-%m-%d")
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1)
ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""",
(site_id, day),
)
conn.commit()
except Exception:
logger.exception("_track_view")
def _form_config(conn, collection_id: int) -> dict:
row = conn.execute(
"SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not row:
raise HTTPException(404, "Collection not found")
try:
cfg = json.loads(row["form_config_json"] or "{}")
except Exception:
cfg = {}
return {"id": row["id"], "name": row["name"], "config": cfg}
def _check_form_rate(ip: str) -> None:
now = time.time()
start, count = _form_rate.get(ip, (now, 0))
if now - start > _FORM_RATE_WINDOW:
_form_rate[ip] = (now, 1)
return
if count >= _FORM_RATE_MAX:
raise HTTPException(429, "Too many submissions. Try again later.")
_form_rate[ip] = (start, count + 1)
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
@router.post("/api/v2/sites")
async def create_site(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
root_page_id = body.get("root_page_id")
if not root_page_id:
raise HTTPException(400, "root_page_id is required")
slug = (body.get("slug") or "").strip().lower() or None
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
if not page:
raise HTTPException(404, "Root page not found")
if not slug:
slug = _slugify(page["title"])
base, i = slug, 1
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
slug = f"{base}-{i}"
i += 1
else:
_check_slug(slug)
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
raise HTTPException(409, "Slug already taken")
theme = body.get("theme", "dark")
if theme not in ("light", "dark"):
raise HTTPException(400, "theme must be light or dark")
custom_domain = (body.get("custom_domain") or "").strip() or None
if custom_domain and conn.execute(
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
).fetchone():
raise HTTPException(409, "Domain already linked to another site")
expires_at = body.get("expires_at")
if expires_at:
try:
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except Exception:
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
cur = conn.execute(
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
expires_at, noindex, analytics_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(slug, root_page_id, body.get("title") or page["title"],
theme, custom_domain, expires_at,
1 if body.get("noindex") else 0,
(body.get("analytics_id") or "")[:120], user["id"]),
)
site_id = cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
(site_id, root_page_id),
)
conn.commit()
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
return JSONResponse(status_code=201, content=row_to_dict(site))
@router.get("/api/v2/sites")
async def list_sites(request: Request):
user = _auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
).fetchone()[0]
rows = conn.execute(
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
(user["id"], limit, offset),
).fetchall()
resp = JSONResponse([row_to_dict(r) for r in rows])
for k, v in paginate_headers(total).items():
resp.headers[k] = v
return resp
@router.get("/api/v2/sites/{site_id}")
async def get_site(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row:
raise HTTPException(404, "Site not found")
site = dict(row)
if site.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
pages = _site_pages(conn, site_id)
out = row_to_dict(row)
out["pages"] = pages
return out
@router.patch("/api/v2/sites/{site_id}")
async def update_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row:
raise HTTPException(404, "Site not found")
site = dict(row)
if site.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
updates: dict = {}
if "title" in body:
updates["title"] = str(body["title"] or "")[:200]
if "theme" in body:
if body["theme"] not in ("light", "dark"):
raise HTTPException(400, "theme must be light or dark")
updates["theme"] = body["theme"]
if "slug" in body and body["slug"] != site["slug"]:
_check_slug(str(body["slug"]).lower())
if conn.execute(
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
).fetchone():
raise HTTPException(409, "Slug already taken")
updates["slug"] = str(body["slug"]).lower()
if "custom_domain" in body:
dom = (body["custom_domain"] or "").strip() or None
if dom and conn.execute(
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
).fetchone():
raise HTTPException(409, "Domain already linked to another site")
updates["custom_domain"] = dom
if "expires_at" in body:
if body["expires_at"]:
try:
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
except Exception:
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
updates["expires_at"] = body["expires_at"]
if "noindex" in body:
updates["noindex"] = 1 if body["noindex"] else 0
if "analytics_id" in body:
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
if "password" in body:
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
if updates:
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
conn.commit()
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
return row_to_dict(site)
@router.delete("/api/v2/sites/{site_id}")
async def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row:
raise HTTPException(404, "Site not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(404, "Site not found")
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
conn.commit()
audit_log(user, "site.delete", "site", site_id, "", request)
return {"status": "deleted", "id": site_id}
@router.get("/api/v2/sites/{site_id}/pages")
async def list_site_pages(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
@router.post("/api/v2/sites/{site_id}/pages")
async def add_site_page(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
).fetchone()[0]
conn.execute(
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
(site_id, page_id, pos),
)
conn.commit()
pages = _site_pages(conn, site_id)
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
return {"site_id": site_id, "pages": pages}
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async def remove_site_page(site_id: int, page_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
if page_id == row["root_page_id"]:
raise HTTPException(400, "Cannot remove the root page")
conn.execute(
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
)
conn.commit()
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
return {"status": "removed", "site_id": site_id, "page_id": page_id}
@router.get("/api/v2/sites/{site_id}/stats")
async def site_stats(site_id: int, request: Request, days: int = 30):
user = _auth_user(request)
days = max(1, min(int(days or 30), 365))
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
raise HTTPException(404, "Site not found")
rows = conn.execute(
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
(site_id, days),
).fetchall()
total = conn.execute(
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
).fetchone()[0]
return {"site_id": site_id, "total_views": total,
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
# ── Public site rendering ──────────────────────────────────────────────────
def _public_guard(site: dict, request: Request):
if _site_expired(site):
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
if site.get("password_hash") and not _site_unlocked(request, site):
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<form method="post" action="/s/{site['slug']}/auth">
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
<input type="password" name="password" placeholder="Password"
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
status_code=401,
)
return None
@router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", ""))
if not site:
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
guard = _public_guard(site, request)
if guard:
return guard
pages = _site_pages(conn, site["id"])
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
if not page:
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
return _site_shell(site=site, pages=pages, current_id=page["id"],
title=page.get("title") or "Untitled", body_html=body)
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async def site_sitemap(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
if not site or _site_expired(site) or site.get("password_hash"):
return PlainTextResponse("Not found", status_code=404)
pages = _site_pages(conn, site["id"])
base = str(request.base_url).rstrip("/")
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
]
return PlainTextResponse(
"<?xml version='1.0' encoding='UTF-8'?>"
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
f"{''.join(urls)}</urlset>",
media_type="application/xml",
)
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async def public_site_page(request: Request, slug: str, page_ref: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site:
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
guard = _public_guard(site, request)
if guard:
return guard
pages = _site_pages(conn, site["id"])
target = _find_site_page(pages, page_ref)
if not target:
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
if not page:
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
return _site_shell(site=site, pages=pages, current_id=page["id"],
title=page.get("title") or "Untitled", body_html=body)
@router.post("/s/{slug}/auth")
async def public_site_auth(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
if not site:
return JSONResponse({"detail": "Site not found"}, status_code=404)
if not site.get("password_hash"):
return {"status": "public"}
ctype = request.headers.get("content-type", "")
password = ""
if "application/json" in ctype:
try:
password = (await request.json()).get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
else:
try:
form = await request.form()
password = form.get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
if not verify_password(password or "", site["password_hash"] or ""):
raise HTTPException(401, "Wrong password")
from itsdangerous import URLSafeTimedSerializer
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
resp = JSONResponse({"status": "unlocked"})
resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]),
httponly=True, samesite="lax", max_age=86400, path="/")
return resp
# ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request):
_auth_user(request)
with get_conn() as conn:
info = _form_config(conn, collection_id)
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
@router.put("/api/v2/collections/{collection_id}/form")
async def put_form_config(collection_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
info = _form_config(conn, collection_id)
cfg = info["config"] if isinstance(info["config"], dict) else {}
if "enabled" in body:
cfg["enabled"] = bool(body["enabled"])
for key in ("title", "success_message"):
if key in body:
cfg[key] = str(body[key] or "")[:300]
for key in ("fields", "required", "notify_user_ids"):
if key in body and isinstance(body[key], list):
cfg[key] = body[key][:50]
if "public_token" in body and body["public_token"]:
tok = str(body["public_token"])
if not _FORM_TOKEN_RE.match(tok):
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
cfg["public_token"] = tok
if cfg.get("enabled") and not cfg.get("public_token"):
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
conn.execute(
"UPDATE collections SET form_config_json=? WHERE id=?",
(json.dumps(cfg), collection_id),
)
conn.commit()
audit_log(user, "form.config", "collection", collection_id, "", request)
return {"collection_id": collection_id, "form": cfg}
def _collection_props(conn, collection_id: int) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()]
@router.get("/f/{token}", response_class=HTMLResponse)
async def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1"
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections").fetchone()
target = None
if _FORM_TOKEN_RE.match(token or ""):
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
try:
cfg = json.loads(c["form_config_json"] or "{}")
except Exception:
continue
if cfg.get("enabled") and cfg.get("public_token") == token:
target = (c, cfg)
break
_ = row
if not target:
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
coll, cfg = target
props = _collection_props(conn, coll["id"])
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
required = set(cfg.get("required") or [])
inputs = ""
for name in fields:
prop = next((p for p in props if p["name"] == name), None)
ptype = (prop or {}).get("prop_type", "text")
itype = {"number": "number", "email": "email", "url": "url",
"date": "date", "phone": "tel"}.get(ptype, "text")
req = "required" if name in required else ""
if ptype in ("select", "status") and prop:
try:
opts = json.loads(prop.get("options_json") or "[]")
except Exception:
opts = []
opts_html = "".join(
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
elif ptype == "checkbox":
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
f"{html.escape(name)}</label>")
else:
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
return HTMLResponse(
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
</head><body>{chrome}
<form method="post" action="/f/{token}">
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
{inputs}<button>Submit</button></form></body></html>"""
)
@router.post("/f/{token}")
async def submit_form(request: Request, token: str):
ip = request.client.host if request.client else "unknown"
_check_form_rate(ip or "unknown")
ctype = request.headers.get("content-type", "")
data: dict = {}
if "application/json" in ctype:
try:
data = await request.json()
except Exception:
data = {}
else:
try:
form = await request.form()
data = dict(form)
except Exception:
data = {}
if data.get("__hp"):
raise HTTPException(400, "Spam detected")
with get_conn() as conn:
target = None
if _FORM_TOKEN_RE.match(token or ""):
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
try:
cfg = json.loads(c["form_config_json"] or "{}")
except Exception:
continue
if cfg.get("enabled") and cfg.get("public_token") == token:
target = (c, cfg)
break
if not target:
# NOTE: return (not raise) — the global 404 handler redirects
# non-/api paths to /workspaces, which would turn this into a 200.
return JSONResponse({"detail": "Form not found"}, status_code=404)
coll, cfg = target
props = _collection_props(conn, coll["id"])
by_name = {p["name"]: p for p in props}
fields = cfg.get("fields") or list(by_name)[:10]
required = set(cfg.get("required") or [])
values: dict = {}
for name in fields:
prop = by_name.get(name)
if not prop:
continue
raw = data.get(name, "")
if prop["prop_type"] == "checkbox":
raw = True if raw in (True, "on", "true", "1", "checked") else False
if name in required and (raw is None or raw == "" or raw is False):
raise HTTPException(400, f"Field required: {name}")
values[str(prop["id"])] = raw
# Validate via property_types.validate_property_rule
try:
from app.services.property_types import validate_property_rule
for name in fields:
prop = by_name.get(name)
if not prop:
continue
ok, _msg = validate_property_rule(
prop.get("prop_type", "text"), values.get(str(prop["id"])),
prop.get("validation_json") or prop.get("options_json") or "")
if not ok:
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
except HTTPException:
raise
except Exception:
logger.exception("submit_form")
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
cur = conn.execute(
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
VALUES (?, ?, ?)""",
(coll["id"], title or "Form response", json.dumps(values)),
)
row_id = cur.lastrowid
ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest()
conn.execute(
"INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)",
(coll["id"], row_id, ip_hash),
)
conn.commit()
notify_ids = cfg.get("notify_user_ids") or []
# Notify (never throws the submission)
try:
from app.services.notifications import create_notification
for uid in notify_ids[:20]:
try:
create_notification(int(uid), None, "form_response",
f"New response: {coll['name']}",
f"{title}", "collection", coll["id"],
f"/db/{coll['id']}")
except Exception:
continue
except Exception:
logger.exception("submit_form")
try:
from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception:
logger.exception("submit_form")
if "application/json" in ctype:
return {"status": "ok", "row_id": row_id,
"message": cfg.get("success_message") or "Merci !"}
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
)
# used by tests to reset the anonymous rate limiter
def _reset_form_rate() -> None:
_form_rate.clear()
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
__all__ = ["router", "get_bearer_user"]
+5 -6
View File
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
if isinstance(_imgs, list) and _imgs:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception:
pass
logger.exception("clip_page")
clip_data = {
"url": url,
"title": title[:200],
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception:
pass
logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
@router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request):
from jinja2 import Environment, FileSystemLoader
from app.routers.dashboard import _sidebar_data
from app.templating import ENV
env = Environment(loader=FileSystemLoader("app/templates"))
env = ENV
try:
sidebar = _sidebar_data(request, [])
except Exception:
@@ -250,7 +249,7 @@ async def extensions_page(request: Request):
devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices)
except Exception:
pass
logger.exception("extensions_page")
content_html = f"""
<style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
+226
View File
@@ -0,0 +1,226 @@
"""FlowDeck — Passkeys / WebAuthn (v7.2.0).
Registration + passwordless login via the ``webauthn`` package (pinned in
requirements). Challenges live in a short-lived in-memory store (5 min,
single-process — same tradeoff as the SSE rooms). RP ID is derived from the
request host. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
router = APIRouter(tags=["webauthn"], prefix="/auth/webauthn")
# key -> (challenge bytes, expires_at). key = f"reg:{user_id}" | f"login:{login}".
_challenges: dict[str, tuple[bytes, float]] = {}
_CHALLENGE_TTL = 300.0
def _require_lib():
try:
import webauthn # noqa: F401
return True
except ImportError:
return False
def _store_challenge(key: str, challenge: bytes) -> None:
_challenges[key] = (challenge, time.time() + _CHALLENGE_TTL)
def _take_challenge(key: str) -> bytes | None:
item = _challenges.pop(key, None)
if not item:
return None
challenge, exp = item
return challenge if exp > time.time() else None
def _rp(request: Request) -> tuple[str, str]:
host = (request.url.hostname or "localhost").split(":")[0]
return host, f"{request.url.scheme}://{request.headers.get('host', host)}"
def _session_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user
@router.post("/register/begin")
async def register_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json
user = _session_user(request)
rp_id, _origin = _rp(request)
with get_conn() as conn:
existing = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
exclude = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
for r in existing]
options = generate_registration_options(
rp_id=rp_id, rp_name="FlowDeck", user_name=user.get("login", f"user{user['id']}"),
user_id=str(user["id"]).encode(), exclude_credentials=exclude or None)
_store_challenge(f"reg:{user['id']}", options.challenge)
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
@router.post("/register/finish")
async def register_finish(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_registration_response
user = _session_user(request)
try:
body = await request.json()
except Exception:
body = {}
challenge = _take_challenge(f"reg:{user['id']}")
if not challenge:
raise HTTPException(400, "Challenge expired — begin again")
rp_id, origin = _rp(request)
try:
verified = verify_registration_response(
credential=body.get("credential") or {},
expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin,
require_user_verification=False)
except Exception as exc: # noqa: BLE001 — invalid attestation → 400, never 500
raise HTTPException(400, f"Registration rejected: {exc}") from None
import base64
cred_id = base64.urlsafe_b64encode(verified.credential_id).decode().rstrip("=")
pubkey = base64.b64encode(bytes(verified.credential_public_key)).decode()
with get_conn() as conn:
try:
cur = conn.execute(
"""INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, sign_count, name)
VALUES (?,?,?,?,?)""",
(user["id"], cred_id, pubkey, verified.sign_count,
str(body.get("name") or "Passkey")[:80]))
conn.commit()
except Exception:
raise HTTPException(409, "Credential already registered") from None
kid = cur.lastrowid
return {"id": kid, "status": "registered"}
@router.post("/login/begin")
async def login_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_authentication_options, options_to_json
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
if not login:
raise HTTPException(400, "login required")
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not user or not user["is_active"]:
raise HTTPException(401, "Invalid credentials")
creds = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
if not creds:
raise HTTPException(400, "No passkeys for this account")
rp_id, _origin = _rp(request)
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
for r in creds]
options = generate_authentication_options(rp_id=rp_id, allow_credentials=allow)
_store_challenge(f"login:{login}", options.challenge)
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
@router.post("/login/finish")
async def login_finish(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import verify_authentication_response
try:
body = await request.json()
except Exception:
body = {}
login = (body.get("login") or "").strip()
challenge = _take_challenge(f"login:{login}")
if not login or not challenge:
raise HTTPException(400, "Challenge expired — begin again")
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not user or not user["is_active"]:
raise HTTPException(401, "Invalid credentials")
stored = conn.execute("SELECT * FROM webauthn_credentials WHERE user_id=?",
(user["id"],)).fetchall()
rp_id, origin = _rp(request)
credential = body.get("credential") or {}
cred_id = (credential.get("id") or "").rstrip("=")
match = next((dict(r) for r in stored if r["credential_id"].rstrip("=") == cred_id), None)
if not match:
raise HTTPException(401, "Unknown credential")
import base64
try:
verified = verify_authentication_response(
credential=credential, expected_challenge=challenge,
expected_origin=origin, expected_rp_id=rp_id,
credential_public_key=base64.b64decode(match["public_key"]),
credential_current_sign_count=match["sign_count"],
require_user_verification=False)
except Exception as exc: # noqa: BLE001
raise HTTPException(401, f"Authentication rejected: {exc}") from None
with get_conn() as conn:
conn.execute("UPDATE webauthn_credentials SET sign_count=? WHERE id=?",
(verified.new_sign_count, match["id"]))
conn.execute("UPDATE users SET last_login=? WHERE id=?",
(str(time.time()), user["id"]))
conn.commit()
ud = dict(conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone())
session = SessionManager.create_session(ud, request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
response.set_cookie("flowdeck_session", session, httponly=True,
max_age=86400 * 7, samesite="lax", path="/")
return response
@router.get("/keys")
async def list_keys(request: Request):
user = _session_user(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
" WHERE user_id=? ORDER BY id", (user["id"],)).fetchall()
return {"keys": [dict(r) for r in rows]}
@router.delete("/keys/{key_id}")
async def delete_key(key_id: int, request: Request):
user = _session_user(request)
with get_conn() as conn:
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
(key_id, user["id"]))
conn.commit()
if not cur.rowcount:
raise HTTPException(404, "Key not found")
return {"status": "deleted", "id": key_id}
def _b64url_to_bytes(data: str) -> bytes:
import base64
padded = data + "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(padded)
def reset_challenges() -> None:
_challenges.clear()
__all__ = ["router", "reset_challenges", "secrets"]
+537
View File
@@ -0,0 +1,537 @@
"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0).
Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``.
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
"""
from __future__ import annotations
import html
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import wiki
from app.services.api_v2_helpers import audit_log
from app.services.notifications import create_notification
from app.templating import ENV
router = APIRouter(tags=["wiki"])
def _esc(value) -> str:
return html.escape(str(value))
def _user(request: Request) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not sess or not sess.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin, is_active FROM users WHERE id=?",
(sess["id"],)).fetchone()
if not row or not row["is_active"]:
raise HTTPException(403, "Account disabled")
return sess
def _workspace_id(request: Request) -> int:
wid = request.query_params.get("workspace_id")
if not wid:
raise HTTPException(400, "workspace_id required")
try:
wid = int(wid)
except (TypeError, ValueError):
raise HTTPException(400, "invalid workspace_id") from None
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM workspaces WHERE id=?", (wid,)).fetchone():
raise HTTPException(404, "Workspace not found")
return wid
def _teamspace_or_404(teamspace_id: int, user_id: int) -> dict:
with get_conn() as conn:
row = conn.execute("SELECT * FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone()
if not row:
raise HTTPException(404, "Teamspace not found")
if not wiki.can_read_teamspace(user_id, teamspace_id):
# private teamspace → 404 (not 403), same as restricted collections
raise HTTPException(404, "Teamspace not found")
return dict(row)
def _page_or_404(page_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, workspace_id, teamspace_id, deleted_at FROM pages WHERE id=?",
(page_id,)).fetchone()
if not row or row["deleted_at"]:
raise HTTPException(404, "Page not found")
return dict(row)
def _is_admin(user: dict) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
return bool(row and row["is_admin"])
def _can_verify(user: dict, page: dict) -> bool:
"""Admin, or an editor/owner of the teamspace / workspace holding the page."""
if _is_admin(user):
return True
if page.get("teamspace_id"):
return wiki.can_write_teamspace(user["id"], page["teamspace_id"])
wid = page.get("workspace_id")
if not wid:
return False
with get_conn() as conn:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
if owner and owner["owner_id"] == user["id"]:
return True
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(wid, user["id"])).fetchone()
return bool(member and member["role"] in ("owner", "admin", "editor"))
# ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request):
user = _user(request)
wid = request.query_params.get("workspace_id")
if wid:
try:
wid = int(wid)
except (TypeError, ValueError):
raise HTTPException(400, "invalid workspace_id") from None
else:
wid = None
return {"teamspaces": wiki.list_teamspaces(user["id"], wid)}
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async def teamspace_page(teamspace_id: int, request: Request):
"""Teamspace detail HTML page — sidebar entry point."""
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
with get_conn() as conn:
ws = conn.execute("SELECT name FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
pages = wiki.teamspace_pages(teamspace_id)
collections = wiki.teamspace_collections(teamspace_id)
page_rows = "\n".join(
f'<a class="ts-row" href="/pages/{p["id"]}" style="display:flex;align-items:center;gap:8px;'
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
f'<span>📄</span><span>{_esc(p["title"] or "Untitled")}</span></a>'
for p in pages)
coll_rows = "\n".join(
f'<a class="ts-row" href="/db/{c["id"]}" style="display:flex;align-items:center;gap:8px;'
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
f'<span>{_esc(c["icon"] or "🗄️")}</span><span>{_esc(c["name"] or "Untitled")}</span></a>'
for c in collections)
content_html = f"""
<div style="max-width:860px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:26px;display:flex;align-items:center;gap:10px;">
{_esc(ts['name'])}{' <span style="font-size:13px;padding:2px 8px;border-radius:10px;background:rgba(76,154,255,.15);color:#4c9aff;">🔒 private</span>' if ts['private'] else ''}
</h1>
<p style="color:var(--text-dim);">{_esc(ts.get('description') or '')}</p>
<div style="display:flex;gap:10px;font-size:12px;color:var(--text-dim);margin-bottom:24px;flex-wrap:wrap;">
<span>Workspace: {_esc((ws["name"]) if ws else '')}</span>
<span>·</span><span>Role: {_esc(ts['role'])}</span>
<span>·</span><span>{len(pages) + len(collections)} items</span>
</div>
<h2 style="font-size:16px;margin:20px 0 8px;">Pages</h2>
<div style="display:flex;flex-direction:column;gap:4px;">
{page_rows or '<p style="color:var(--text-dim);font-size:13px;">No pages yet.</p>'}
</div>
<h2 style="font-size:16px;margin:24px 0 8px;">Databases</h2>
<div style="display:flex;flex-direction:column;gap:4px;">
{coll_rows or '<p style="color:var(--text-dim);font-size:13px;">No databases yet.</p>'}
</div>
</div>
<style>
.ts-row:hover{{background:var(--bg-hover);}}
</style>"""
from app.routers.dashboard import _sidebar_data
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
page_title=ts["name"],
title_prefix="Teamspace",
page_icon="🏛️",
)
@router.post("/api/v2/wiki/teamspaces")
async def create_teamspace(request: Request):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name or len(name) > 120:
raise HTTPException(400, "name required (max 120 chars)")
wid = int(body.get("workspace_id") or 0)
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
if not ws:
raise HTTPException(404, "Workspace not found")
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(wid, user["id"])).fetchone()
allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"])
or (member and member["role"] in ("admin", "editor", "owner")))
if not allowed:
raise HTTPException(403, "Editor role required in the workspace")
try:
tsid = wiki.create_teamspace(wid, name, user["id"],
description=body.get("description") or "",
private=bool(body.get("private")))
except ValueError as exc:
raise HTTPException(409, str(exc)) from None
audit_log(user, "teamspace.create", "teamspace", tsid, name, request)
return JSONResponse(status_code=201, content={"id": tsid, "name": name})
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async def get_teamspace(teamspace_id: int, request: Request):
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id))
return ts
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn:
rows = conn.execute(
"""SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m
JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""",
(teamspace_id,)).fetchall()
return {"members": [dict(r) for r in rows]}
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def set_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id):
raise HTTPException(403, "Editor role required")
try:
body = await request.json()
except Exception:
body = {}
role = body.get("role")
if role not in wiki.TEAMSPACE_ROLES:
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone():
raise HTTPException(404, "User not found")
conn.execute(
"""INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?)
ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""",
(teamspace_id, member_id, role))
conn.commit()
audit_log(user, "teamspace.member.set", "teamspace", teamspace_id,
f"u{member_id}={role}", request)
return {"status": "ok", "user_id": member_id, "role": role}
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def remove_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id):
raise HTTPException(403, "Editor role required")
with get_conn() as conn:
cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, member_id))
conn.commit()
if not cur.rowcount:
raise HTTPException(404, "Not a member")
return {"status": "removed", "user_id": member_id}
# ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
return {"verification": wiki.verification(page_id)}
@router.post("/api/v2/wiki/pages/{page_id}/verify")
async def verify_page(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if not _can_verify(user, page):
raise HTTPException(403, "Editor role required to verify a page")
try:
body = await request.json()
except Exception:
body = {}
out = wiki.verify_page(page_id, user["id"],
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
note=body.get("note") or "")
audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request)
return {"verification": out}
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
if not wiki.unverify_page(page_id):
raise HTTPException(404, "Page is not verified")
audit_log(user, "page.unverify", "page", page_id, "", request)
return {"status": "unverified", "page_id": page_id}
@router.get("/api/v2/wiki/verified")
async def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request)
wid = _workspace_id(request)
with get_conn() as conn:
rows = conn.execute(
"""SELECT p.id, p.title, p.page_icon, p.teamspace_id,
v.verified_at, v.expires_at, v.note, u.login
FROM page_verifications v
JOIN pages p ON p.id = v.page_id
LEFT JOIN users u ON u.id = v.verified_by
WHERE p.workspace_id=? AND p.deleted_at IS NULL""",
(wid,)).fetchall()
out = []
for r in rows:
item = dict(r)
if wiki.is_expired(r):
continue # badge lapsed → not listed
if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"],
item["teamspace_id"]):
continue # private teamspace → hidden
item["active"] = True
out.append(item)
return {"pages": out}
# ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"])
return {"page_id": page_id, "following": now}
@router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
ids = wiki.followers(page_id)
if not ids:
return {"followers": []}
with get_conn() as conn:
rows = conn.execute(
f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})",
ids).fetchall()
return {"followers": [dict(r) for r in rows]}
# ── comment reactions ──────────────────────────────────────────────────────
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
async def react(comment_id: int, request: Request):
user = _user(request)
try:
body = await request.json()
except Exception:
body = {}
emoji = (body.get("emoji") or "").strip()
if not emoji:
raise HTTPException(400, "emoji required")
try:
counts = wiki.toggle_reaction(comment_id, user["id"], emoji)
except LookupError:
raise HTTPException(404, "Comment not found") from None
return {"comment_id": comment_id, "reactions": counts}
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request):
_user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
# ── guest shares ───────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/guests")
async def create_guest(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
raise HTTPException(403, "Editor role required to share")
try:
body = await request.json()
except Exception:
body = {}
try:
share = wiki.create_guest_share(page_id, body.get("email") or "",
body.get("role") or "viewer",
user["id"], days=body.get("days", 30))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
audit_log(user, "page.guest_share", "page", page_id, share["email"], request)
return JSONResponse(status_code=201, content={
"id": share["id"], "token": share["token"], "role": share["role"],
"expires_at": share["expires_at"], "url": f"/g/{share['token']}"})
@router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares"
" WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall()
return {"guests": [dict(r) for r in rows]}
@router.delete("/api/v2/wiki/guests/{share_id}")
async def revoke_guest(share_id: int, request: Request):
user = _user(request)
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
raise HTTPException(404, "Guest share not found")
conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,))
conn.commit()
audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request)
return {"status": "revoked", "id": share_id}
_GUEST_404 = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Link unavailable — FlowDeck</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:520px;
margin:80px auto;padding:0 20px;color:#1f2328;text-align:center}
h1{font-size:20px} p{color:#656d76;line-height:1.6}</style></head><body>
<h1>This link is unavailable</h1>
<p>It may have expired, been revoked, or never existed.<br>
Ask the person who shared it with you for a new link.</p></body></html>"""
@router.get("/g/{token}", response_class=HTMLResponse)
async def guest_page(token: str, request: Request):
"""Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token)
if not share:
return HTMLResponse(_GUEST_404, status_code=404)
with get_conn() as conn:
page = conn.execute("SELECT id, title, content, created_at, updated_at, deleted_at"
" FROM pages WHERE id=?", (share["page_id"],)).fetchone()
if not page or page["deleted_at"]:
return HTMLResponse(_GUEST_404, status_code=404)
wiki.record_view(share["page_id"])
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{page['title']} — FlowDeck guest</title>
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
margin:40px auto;padding:0 20px;line-height:1.6;color:#1f2328}}
.guest-banner{{background:#fff4e5;border:1px solid #ffd8a8;padding:10px 14px;
border-radius:8px;margin-bottom:24px;font-size:14px}}
pre{{background:#f6f8fa;padding:14px;border-radius:8px;overflow:auto;
white-space:pre-wrap;word-break:break-word}}</style></head><body>
<div class="guest-banner">You are viewing this page as a guest
({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}).
Editing is disabled.</div>
<h1>{page['title']}</h1><pre>{page['content'] or ''}</pre></body></html>"""
# ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views")
async def page_views(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
raise HTTPException(403, "Editor role required to read analytics")
return wiki.view_stats(page_id, days=request.query_params.get("days", 30))
# ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home")
async def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request)
wid = _workspace_id(request)
with get_conn() as conn:
recents = conn.execute(
"""SELECT id, title, page_icon, updated_at FROM pages
WHERE workspace_id=? AND deleted_at IS NULL
ORDER BY updated_at DESC LIMIT 20""", (wid,)).fetchall()
verified = conn.execute(
"""SELECT v.page_id, v.verified_at, v.expires_at FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE p.workspace_id=? AND p.deleted_at IS NULL
AND (v.expires_at IS NULL OR v.expires_at > ?)""",
(wid, __import__("datetime").datetime.now(
__import__("datetime").timezone.utc).replace(microsecond=0).isoformat()),
).fetchall()
return {"workspace_id": wid,
"teamspaces": wiki.list_teamspaces(user["id"], wid),
"verified": [dict(r) for r in verified],
"recents": [dict(r) for r in recents]}
@router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request)
with get_conn() as conn:
if not conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()[0]:
raise HTTPException(403, "Admin required")
sent = 0
for row in wiki.expiring_verifications(days=7):
create_notification(row["owner_id"], None, "page.verification_expiring",
"Verification expiring soon",
f"“{row['title']}” loses its verified badge on {row['expires_at']}.",
resource_type="page", resource_id=row["page_id"])
sent += 1
return {"notified": sent}
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
@router.post("/api/v2/wiki/blocks/preview")
async def preview_blocks(request: Request):
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
_user(request)
try:
body = await request.json()
except Exception:
body = {}
blocks = body.get("blocks")
if not isinstance(blocks, list):
raise HTTPException(400, "blocks must be a list")
if len(blocks) > 200:
raise HTTPException(400, "max 200 blocks per preview")
from app.services.wiki_blocks import mmdc_available, render_block
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
return {"rendered": out, "mmdc_available": mmdc_available()}
+216
View File
@@ -0,0 +1,216 @@
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import workers as worker_service
from app.services.api_v2_helpers import (
audit_log,
has_scope,
paginate_headers,
parse_pagination,
resolve_bearer_token,
row_to_dict,
)
router = APIRouter(tags=["workers"])
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if sess:
return sess
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
user = resolve_bearer_token(auth[7:].strip())
if not user:
raise HTTPException(401, "Invalid or expired API token")
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
return user
raise HTTPException(401, "Authentication required")
def _row_to_api(row) -> dict:
d = row_to_dict(row)
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
return d
@router.post("/api/v2/workers")
async def create_worker(request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Untitled worker").strip()[:200]
code = body.get("code_py") or ""
try:
worker_service.validate_code(code)
except worker_service.WorkerRejected as exc:
raise HTTPException(400, f"code rejected: {exc}") from None
slug = worker_service.unique_slug(body.get("slug") or name)
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
shared, daily_budget_s, created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(slug, body.get("workspace_id"), name, code,
(body.get("schedule_cron") or "")[:60],
1 if body.get("shared") else 0,
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
user["id"]))
conn.commit()
wid = cur.lastrowid
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
audit_log(user, "worker.create", "worker", wid, slug, request)
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
@router.get("/api/v2/workers")
async def list_workers(request: Request):
_auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
rows = conn.execute(
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
resp = JSONResponse([_row_to_api(r) for r in rows])
for k, v in paginate_headers(total).items():
resp.headers[k] = v
return resp
@router.get("/api/v2/workers/{worker_id}")
async def get_worker(worker_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
out = _row_to_api(row)
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
or user.get("is_admin")):
out["code_py"] = row["code_py"]
return out
@router.patch("/api/v2/workers/{worker_id}")
async def update_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the owner can update this worker")
updates: dict = {}
if "name" in body:
updates["name"] = str(body["name"] or "")[:200]
if "code_py" in body:
try:
worker_service.validate_code(body["code_py"] or "")
except worker_service.WorkerRejected as exc:
raise HTTPException(400, f"code rejected: {exc}") from None
updates["code_py"] = body["code_py"] or ""
if "schedule_cron" in body:
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
if "shared" in body:
updates["shared"] = 1 if body["shared"] else 0
if "daily_budget_s" in body:
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
if "slug" in body and body["slug"] != row["slug"]:
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
raise HTTPException(400, "Invalid slug")
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
(body["slug"], worker_id)).fetchone():
raise HTTPException(409, "Slug already taken")
updates["slug"] = body["slug"]
if updates:
sets = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(*updates.values(), worker_id))
conn.commit()
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
return _row_to_api(row)
@router.delete("/api/v2/workers/{worker_id}")
async def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
if row["created_by"] != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the owner can delete this worker")
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
conn.commit()
audit_log(user, "worker.delete", "worker", worker_id, "", request)
return {"status": "deleted", "id": worker_id}
@router.post("/api/v2/workers/{worker_id}/run")
async def run_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
try:
body = await request.json() if request.headers.get("content-type") else {}
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
if (row["created_by"] != user["id"] and not row["shared"]
and not user.get("is_admin")):
raise HTTPException(403, "Worker is private")
import asyncio
loop = asyncio.get_running_loop()
try:
out = await loop.run_in_executor(
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
except HTTPException:
raise
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
return out
@router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request):
_auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn:
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
raise HTTPException(404, "Worker not found")
rows = conn.execute(
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
(worker_id, limit)).fetchall()
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
@router.post("/api/v2/workers/{worker_id}/fork")
async def fork_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
out = worker_service.fork_worker(worker_id, user["id"])
audit_log(user, "worker.fork", "worker", worker_id, "", request)
return JSONResponse(status_code=201, content=out)
@router.get("/api/v2/workers-usage")
async def workers_usage(request: Request):
user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id")
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
return {"workspace_id": wid,
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
"user_id": user.get("id")}
+69 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import csv
import html
import io
import json
import logging
@@ -25,15 +26,36 @@ def _current_user(request: Request) -> dict:
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
# ── Workspaces ──
def _require_admin(request: Request) -> dict:
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
if not user.get("is_admin"):
raise HTTPException(403, "Admin only")
return user
@router.get("")
async def list_workspaces(request: Request):
def _require_ws_admin(request: Request, ws_id: int) -> None:
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
promouvoir admin."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
return {"workspaces": [dict(r) for r in rows]}
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
return
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ws_id, user["id"]),
).fetchone()
if not row or row["role"] != "admin":
raise HTTPException(403, "Workspace admin role required")
# ── Workspaces ──
@router.post("")
async def create_workspace(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
@@ -58,6 +80,8 @@ async def create_workspace(request: Request):
@router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
rows = conn.execute(
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
@@ -68,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
@router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
(user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role))
@@ -84,6 +109,7 @@ async def add_member(request: Request, ws_id: int):
@router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor")
if role not in ROLES:
@@ -97,6 +123,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
conn.commit()
@@ -133,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"}
@@ -153,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"status": "updated"}
@@ -219,7 +246,7 @@ async def add_favorite(request: Request):
try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
except Exception:
pass
logger.exception("add_favorite")
return {"status": "favorited"}
@@ -466,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
except Exception:
pass
logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -496,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
except Exception:
pass
logger.exception("update_sprint")
return {"id": sid, "status": "updated"}
@@ -664,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
@router.get("/webhooks")
async def list_webhooks(request: Request):
_require_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
return {"webhooks": [dict(r) for r in rows]}
@@ -671,12 +699,20 @@ async def list_webhooks(request: Request):
@router.post("/webhooks")
async def create_webhook(request: Request):
_require_admin(request)
body = await request.json() if request.headers.get("content-type") else {}
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
if not url:
raise HTTPException(400, "url required")
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
from urllib.parse import urlparse
from app.services.importers.url_fetch import _is_public_host
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
@@ -688,6 +724,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int):
_require_admin(request)
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
conn.commit()
@@ -698,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required."""
"""Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
sur le titre de la base ou d'une ligne).
"""
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
if ptype in ("restricted", "private"):
# 404 explicite : le handler global transformerait un HTTPException(404)
# en redirection 302 → login pour un chemin HTML.
return HTMLResponse(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
"<body><h1>404 — Not found</h1></body></html>",
status_code=404,
)
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
esc = html.escape
name = esc(str(coll["name"] or ""))
icon = esc(str(coll["icon"] or ""))
items = "".join(
f"<li>{p['icon']} <b>{p['title']}</b></li>"
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
for p in pages
)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
+29 -3
View File
@@ -18,9 +18,10 @@ import re
from app.config import settings
from app.db import get_conn
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.permission_manager import WRITE_TOOLS, PermissionManager
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -185,7 +186,10 @@ class AgentEngine:
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
# v7.2.0 — the workspace policy may cap iterations below the global max.
policy_max = get_policy(self.workspace_id).get("max_steps") or MAX_ITERATIONS
iterations = min(settings.agent_max_iterations or MAX_ITERATIONS, policy_max)
for _step in range(iterations):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
@@ -235,8 +239,30 @@ class AgentEngine:
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
# v7.2.0 — workspace tool scope + human approval gate, checked
# *before* permissions so a scoped-out tool never reaches ACLs.
gov = check_tool(self.user_id, self.workspace_id, tool,
is_write=tool in WRITE_TOOLS,
conversation_id=conversation_id)
if not gov.get("allowed"):
detail = gov.get("reason") or "Refusé par la politique agent"
if gov.get("approval_id"):
detail = (f"Approbation requise (demande #{gov['approval_id']}) "
f"— action suspendue")
yield self._event("action", {
"tool": tool, "status": "approval_required" if gov.get("approval_id")
else "error", "detail": detail,
"approval_id": gov.get("approval_id")})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": detail},
ensure_ascii=False),
})
denied = True
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
if not denied:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
+89
View File
@@ -0,0 +1,89 @@
"""FlowDeck — agent governance (v7.2.0): workspace tool scope + approval gate.
``agent_policies``: ``allowed_tools_json`` (null = all tools), ``max_steps``,
``require_approval`` (write tools pause for a human). ``check_tool()`` is
consulted by ``AgentEngine`` before ``PermissionManager.assert_can``.
``agent.run.approval_requested`` is emitted on the outbound webhook bus so
external systems can subscribe. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import json
from app.db import get_conn
def get_policy(workspace_id: int | None) -> dict:
"""Effective policy (workspace row, else global row, else defaults)."""
with get_conn() as conn:
row = None
if workspace_id is not None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id=?",
(workspace_id,)).fetchone()
if row is None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS NULL"
).fetchone()
if not row:
return {"allowed_tools": None, "max_steps": 12, "require_approval": False}
d = dict(row)
try:
allowed = json.loads(d.get("allowed_tools_json")) if d.get("allowed_tools_json") else None
except (TypeError, ValueError):
allowed = None
return {"allowed_tools": allowed, "max_steps": d.get("max_steps") or 12,
"require_approval": bool(d.get("require_approval"))}
def check_tool(user_id: int, workspace_id: int | None, tool: str,
is_write: bool, conversation_id: int = 0) -> dict:
"""Policy gate for one tool call.
Returns {allowed: bool, approval_id: int|None}. Denied tools and gated
writes (pending approval) return allowed=False; the engine renders both
as action errors without executing.
"""
from app.services.permission_manager import WRITE_TOOLS
policy = get_policy(workspace_id)
allowed = policy["allowed_tools"]
if allowed is not None and tool not in set(allowed):
return {"allowed": False, "approval_id": None, "reason": "tool not in policy scope"}
if is_write or tool in WRITE_TOOLS:
if policy["require_approval"]:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO agent_approvals
(conversation_id, tool, args_json, status, requester_id)
VALUES (?,?,?,?,?)""",
(conversation_id, tool, "{}", "pending", user_id))
conn.commit()
approval_id = cur.lastrowid
try:
import asyncio
from app.services.webhook_outbound import fire_event as _fire
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(_fire("agent.run.approval_requested", {
"approval_id": approval_id, "tool": tool,
"conversation_id": conversation_id}))
except Exception: # noqa: BLE001 — webhook never blocks policy
pass
return {"allowed": False, "approval_id": approval_id,
"reason": "approval requested"}
return {"allowed": True, "approval_id": None, "reason": ""}
def decide_approval(approval_id: int, approver_id: int, approve: bool) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone()
if not row or row["status"] != "pending":
return None
conn.execute("UPDATE agent_approvals SET status=?, approver_id=? WHERE id=?",
("approved" if approve else "rejected", approver_id, approval_id))
conn.commit()
return dict(conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone())
+13 -6
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import time
from datetime import UTC, datetime
from typing import Any
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None:
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception:
pass
logger.exception("row_to_dict")
return d
# ── Pagination ────────────────────────────────────────────────────────────
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
if dt.timestamp() < time.time():
return None
except Exception:
pass
logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
d = dict(u)
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
except Exception:
pass
logger.exception("resolve_bearer_token")
return d
# 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
@@ -211,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
return user
def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read"
# Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user
@@ -257,7 +264,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 +314,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
)
conn.commit()
except Exception:
pass
logger.exception("store_idempotency")
+423 -2
View File
@@ -23,7 +23,8 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
import time
from datetime import UTC, datetime, timedelta
import httpx
@@ -167,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
url = action.get("url", "").strip()
if not url:
raise ValueError("webhook action requires a url")
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
from urllib.parse import urlparse as _urlparse
from app.services.importers.url_fetch import _is_public_host
_parsed = _urlparse(url)
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
secret = action.get("secret", "")
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret:
@@ -246,6 +254,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
)
return f"notified user {user_id}"
if atype == "slack":
url = _secret_value(action.get("webhook_url") or action.get("url") or "")
if not url:
raise ValueError("slack action requires a webhook_url")
_, text = _maybe_convert_prediction(
action.get("text") or action.get("message") or "Automation fired", context)
return await _post_slack(url, text)
if atype == "email":
to = action.get("to", "")
_, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context)
_, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context)
return await _send_email_action(to, subject, body, context)
if atype == "forge_issue":
provider = (action.get("provider") or "gitea").lower()
owner = action.get("owner", "")
repo = action.get("repo", "")
if not owner or not repo:
raise ValueError("forge_issue requires owner + repo")
_, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context)
_, body = _maybe_convert_prediction(action.get("body", ""), context)
return await _create_forge_issue(
provider, owner, repo, title, body,
labels=action.get("labels") or [],
user_id=context.get("created_by"),
)
if atype == "agent_trigger":
agent_id = action.get("agent_id")
if not agent_id:
raise ValueError("agent_trigger requires an agent_id")
_, message = _maybe_convert_prediction(action.get("message", ""), context)
return await _run_linked_agent(
int(agent_id), context.get("created_by") or 1,
context.get("workspace_id"), message, context)
raise ValueError(f"unknown action type: {atype!r}")
@@ -260,6 +305,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
if not auto["enabled"]:
return {"status": "skipped", "detail": "automation disabled"}
# v7.0.0: chained steps take over when present (legacy path otherwise).
stepped = await _maybe_run_stepped(auto, trigger_source, context)
if stepped is not None:
return stepped
props = context.get("properties")
before = context.get("before_properties")
if not evaluate_conditions(auto["condition_json"], props, before):
@@ -307,6 +357,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
if event == "page.updated":
try:
from app.services.wiki import notify_followers_of_page_update
notify_followers_of_page_update(
payload.get("page_id"), payload.get("actor_id"),
payload.get("title") or "")
except Exception: # noqa: BLE001 — notifications are best-effort
logger.debug("followers notification failed for page.updated")
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
try:
from app.services.webhook_outbound import fire_event as fire_webhooks
@@ -320,14 +379,29 @@ async def fire_event(event: str, payload: dict):
WHERE trigger_type='event' AND event=? AND enabled=1""",
(event,),
).fetchall()
stepped_ids: set[int] = set()
try:
with get_conn() as _c:
stepped_ids = {r[0] for r in _c.execute(
"SELECT DISTINCT automation_id FROM automation_steps").fetchall()}
except Exception: # noqa: BLE001 — table missing on very old DBs
pass
for row in rows:
auto = dict(row)
if auto["id"] in stepped_ids:
continue # v7.0.0: handled by fire_stepped_event below (no double run)
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
continue
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# v7.0.0: step-based automations (multi-trigger any/all, chains).
try:
await fire_stepped_event(event, payload)
except Exception: # noqa: BLE001
logger.debug("stepped dispatch failed for %s", event)
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
@@ -346,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
expr = (expression or "").strip().lower()
if not expr:
return False
now = now or datetime.utcnow()
now = now or datetime.now(UTC).replace(tzinfo=None)
minute = now.minute
fields = expr.split()
@@ -409,6 +483,353 @@ async def automation_scheduler():
await run_automation(auto["id"], "cron", context)
except Exception: # noqa: BLE001
logger.warning("Cron automation %s errored", auto["id"])
# v7.0.0: workers on a cron schedule share the same 60s loop.
try:
from app.services.workers import run_due_workers
await run_due_workers()
except Exception: # noqa: BLE001
logger.warning("worker cron iteration failed")
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
STEP_KINDS = ("trigger", "condition", "delay", "action")
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
"slack", "email", "forge_issue", "agent_trigger")
ALL_MODE_WINDOW_S = 300.0
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
_ALL_PENDING: dict[int, dict[str, float]] = {}
def reset_all_pending() -> None:
"""Test helper: clear the mode=all arrival window."""
_ALL_PENDING.clear()
def _secret_value(stored: str | None) -> str:
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
if not stored:
return ""
try:
from app.services.sso_provisioning import decrypt_secret
decrypted = decrypt_secret(stored)
if decrypted:
return decrypted
except Exception: # noqa: BLE001
pass
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
return stored
return ""
def validate_step(kind: str, config: dict) -> None:
"""Validate a step payload. Raises ValueError with a human message."""
from fastapi import HTTPException
if kind not in STEP_KINDS:
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
config = config or {}
if kind == "trigger":
if not config.get("event"):
raise HTTPException(400, "trigger step requires an event")
elif kind == "condition":
if config.get("op", "eq") not in COND_OPS:
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
elif kind == "delay":
try:
seconds = int(config.get("seconds", 0))
except (TypeError, ValueError):
raise HTTPException(400, "delay step requires integer seconds") from None
if seconds < 0 or seconds > 86400:
raise HTTPException(400, "delay seconds must be 0..86400")
elif kind == "action":
if config.get("type") not in STEP_ACTION_TYPES:
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
def get_steps(automation_id: int) -> list[dict]:
"""Ordered steps of an automation (empty when legacy single-mode)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
(automation_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
try:
d["config"] = json.loads(d.get("config_json") or "{}")
except (TypeError, json.JSONDecodeError):
d["config"] = {}
out.append(d)
return out
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
"delay": [], "action": []}
for s in steps:
if s.get("kind") in grouped:
grouped[s["kind"]].append(s)
return grouped
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
automation_collection_id: int | None) -> bool:
if step_cfg.get("event") != event:
return False
want_coll = step_cfg.get("collection_id") or automation_collection_id
if want_coll and payload.get("collection_id") != want_coll:
return False
return True
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
context: dict) -> dict:
"""Execute a chained automation. Records one run row with per-step detail."""
grouped = _steps_by_kind(steps)
props = context.get("properties")
before = context.get("before_properties")
# Legacy single condition still applies on top of step conditions.
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "condition not met"}
for cond in grouped["condition"]:
cfg = cond.get("config") or {}
if not match_condition_props(props, before, {
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
"value": cfg.get("value")}):
_save_run(auto["id"], trigger_source, "skipped",
f"step condition not met: {cfg.get('property')}",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "step condition not met"}
ctx = dict(context)
ctx["automation_name"] = auto["name"]
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
results = []
try:
for step in ordered:
kind = step.get("kind")
cfg = step.get("config") or {}
if kind in ("trigger", "condition"):
continue
if kind == "delay":
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
if seconds:
await asyncio.sleep(seconds)
results.append(f"delay {cfg.get('seconds', 0)}s")
elif kind == "action":
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
trigger_source)
results.append(summary)
detail = "; ".join(results) or "no steps executed"
_save_run(auto["id"], trigger_source, "fired", detail,
ctx.get("collection_id"), ctx.get("page_id"))
try:
from app.services.webhook_outbound import fire_event as _fire_wh
await _fire_wh("automation.fired", {
"automation_id": auto["id"], "name": auto["name"],
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
"page_id": ctx.get("page_id"), "detail": detail})
except Exception: # noqa: BLE001
logger.debug("automation.fired webhook dispatch failed")
return {"status": "fired", "detail": detail}
except Exception as exc: # noqa: BLE001
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
_save_run(auto["id"], trigger_source, "error", str(exc),
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "error", "detail": str(exc)}
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
"""Run via steps when the automation has any; None → use legacy path."""
steps = get_steps(auto["id"])
if not steps:
return None
return await _run_with_steps(auto, steps, trigger_source, context)
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT a.* FROM automations a
JOIN automation_steps s ON s.automation_id = a.id
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
).fetchall()
matched = []
for row in rows:
auto = dict(row)
steps = get_steps(auto["id"])
triggers = [s for s in steps if s.get("kind") == "trigger"]
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
auto.get("collection_id")) for t in triggers):
matched.append((auto, triggers))
return matched
async def fire_stepped_event(event: str, payload: dict) -> None:
"""Dispatch ``event`` to step-based automations (mode any/all).
Called from :func:`fire_event` after the legacy matcher. Unknown events
(not in the webhook catalogue) still work here — steps are independent
from outbound webhooks.
"""
now = time.time()
for auto, triggers in _match_stepped_automations(event, payload):
# Skip automations already handled by the legacy matcher to avoid
# double runs (legacy = trigger_type event + no steps).
if not get_steps(auto["id"]):
continue
mode = (auto.get("trigger_mode") or "any").lower()
if mode == "all":
pending = _ALL_PENDING.setdefault(auto["id"], {})
pending[event] = now
# Expire arrivals outside the window.
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
del pending[ev]
wanted = {t.get("config", {}).get("event") for t in triggers}
if not wanted <= set(pending):
continue
_ALL_PENDING.pop(auto["id"], None)
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
async def _post_slack(webhook_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json={"text": text})
if resp.status_code >= 400:
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
return f"slack → ({resp.status_code})"
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
from app.services import mailer
address = (to or "").strip()
if address.startswith("user:"):
try:
uid = int(address.split(":", 1)[1])
except ValueError:
raise ValueError(f"bad email target: {to!r}") from None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
address = (row["email"] if row and row["email"] else "")
if not address:
raise ValueError(f"user {uid} has no email")
if not address:
address = None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?",
(context.get("created_by") or 1,)).fetchone()
if row and row["email"]:
address = row["email"]
if not address:
return "email skipped (no recipient)"
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
body: str, labels: list | None = None,
user_id: int | None = None) -> str:
token = ""
if user_id:
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
(user_id, provider)).fetchone()
token = (row["access_token"] if row else "") or ""
if provider == "github":
if not token:
raise ValueError("github action needs a linked GitHub account (token)")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues",
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"},
json={"title": title, "body": body,
"labels": labels or []} if labels else {"title": title, "body": body},
)
if resp.status_code >= 400:
raise RuntimeError(f"github returned HTTP {resp.status_code}")
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
# gitea (default)
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token or None)
issue = await gitea.create_issue(owner, repo, title, body)
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
message: str, context: dict) -> str:
from app.services.agent_engine import AgentEngine
with get_conn() as conn:
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not agent:
raise ValueError(f"agent {agent_id} not found")
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent_id, user_id,
f"Automation: {context.get('automation_name', 'run')}",
json.dumps({"workspace_id": workspace_id})),
)
conv_id = cur.lastrowid
conn.commit()
objective = ((agent["system_instructions"] or "").strip()
or f"Exécute l'agent « {agent['name']} ».")
if message:
objective = f"{objective}\n\n{message}"
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
final = ""
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
final = (row["content"][:300] if row and row["content"] else "")
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
# ── v7.0.0 native DB button ────────────────────────────────────────────────
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
user_id: int) -> dict:
"""Run the automation linked to a ``button`` property cell."""
with get_conn() as conn:
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
(int(prop_ref), collection_id)).fetchone()
else:
prop = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, prop_ref)).fetchone()
if not prop:
raise ValueError("button property not found")
prop = dict(prop)
if prop.get("prop_type") != "button":
raise ValueError("property is not a button")
auto_id = prop.get("button_automation_id")
if not auto_id:
raise ValueError("button has no linked automation")
row = conn.execute(
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
(row_id, collection_id)).fetchone()
if not row:
raise ValueError("row not found")
context = get_page_context(row_id, collection_id)
context["created_by"] = user_id
return await run_automation(auto_id, "button", context)
+1 -1
View File
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
logger.exception("backup_db")
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+528
View File
@@ -0,0 +1,528 @@
"""FlowDeck — external calendar sync (v7.1.0).
Bidirectional sync between a collection (date property) and an external
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
extra dependency). Tokens are Fernet-encrypted at rest.
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
Conflicts (both sides changed since ``last_sync``): last-write-wins +
in-app ``calendar.conflict`` notification (manual edit resolves).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn
logger = logging.getLogger(__name__)
PROVIDERS = ("google", "caldav")
SYNC_LOOKBACK_DAYS = 30
SYNC_LOOKAHEAD_DAYS = 90
class SyncError(RuntimeError):
"""Raised when the remote calendar cannot be reached/authorized."""
# ── links ──────────────────────────────────────────────────────────────────
def _encrypt_tokens(creds: dict) -> str:
from app.services.sso_provisioning import encrypt_secret
return encrypt_secret(json.dumps(creds or {}))
def _decrypt_tokens(tokens_enc: str) -> dict:
if not tokens_enc:
return {}
try:
from app.services.sso_provisioning import decrypt_secret
raw = decrypt_secret(tokens_enc)
if raw:
return json.loads(raw)
except Exception: # noqa: BLE001
pass
try: # legacy plaintext (tests)
data = json.loads(tokens_enc)
return data if isinstance(data, dict) else {}
except Exception: # noqa: BLE001
return {}
def save_link(user_id: int, provider: str, collection_id: int,
credentials: dict, calendar_id: str = "primary",
date_property: str = "") -> dict:
if provider not in PROVIDERS:
raise ValueError(f"provider must be google|caldav, got {provider!r}")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?",
(collection_id,)).fetchone():
raise ValueError("collection not found")
cur = conn.execute(
"""INSERT INTO calendar_links
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
VALUES (?,?,?,?,?,?)
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
date_property=excluded.date_property""",
(user_id, provider, _encrypt_tokens(credentials),
calendar_id or "primary", collection_id, date_property or ""))
conn.commit()
row = conn.execute(
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
(user_id, provider, calendar_id or "primary")).fetchone()
_ = cur
out = dict(row)
out.pop("tokens_enc", None)
return out
def list_links(user_id: int) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, user_id, provider, calendar_id, collection_id,"
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
" ORDER BY id", (user_id,)).fetchall()
return [dict(r) for r in rows]
def delete_link(user_id: int, link_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
(link_id, user_id))
conn.commit()
return cur.rowcount > 0
def _load_link(link_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
return dict(row) if row else None
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
def _remote_event(eid: str, title: str, start: str, description: str = "",
updated: str = "") -> dict:
return {"id": str(eid), "title": title or "Untitled", "start": start,
"description": description or "", "updated": updated or ""}
async def google_list_events(tokens: dict, calendar_id: str,
time_min: str, time_max: str) -> list[dict]:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
f"/events?singleEvents=true&orderBy=startTime"
f"&timeMin={time_min}&timeMax={time_max}")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
out = []
for item in resp.json().get("items", []):
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
start, item.get("description", ""),
item.get("updated", "")))
return out
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
remote_id: str = "") -> str:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
body = {"summary": event.get("title", ""),
"description": event.get("description", ""),
"start": {"date": event.get("start", "")[:10]},
"end": {"date": event.get("start", "")[:10]}}
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
async with httpx.AsyncClient(timeout=15) as client:
if remote_id:
resp = await client.patch(f"{base}/{remote_id}",
headers={"Authorization": f"Bearer {access}"}, json=body)
else:
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
json=body)
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
return str(resp.json().get("id", remote_id or ""))
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop><D:getetag/><C:calendar-data/></D:prop>
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter></C:comp-filter></C:filter>
</C:calendar-query>"""
def _parse_caldav_events(xml_text: str) -> list[dict]:
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
import re
import xml.etree.ElementTree as ET
events = []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return []
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
for resp in root.findall("D:response", ns):
href = resp.findtext("D:href", default="", namespaces=ns)
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
if data_el is None or not data_el.text:
continue
ics = data_el.text
uid = re.search(r"^UID:(.+)$", ics, re.M)
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
events.append(_remote_event(
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
summary.group(1).strip() if summary else "Untitled",
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
desc.group(1).strip() if desc else ""))
return events
def _ics_to_date(value: str) -> str:
value = value.strip()
if len(value) >= 8 and value[:8].isdigit():
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
return value[:10]
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
day = (date or "")[:10].replace("-", "")
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
url = creds.get("url", "")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
body = _CALDAV_REPORT.format(
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.request("REPORT", url, content=body,
headers={"Depth": "1",
"Content-Type": "application/xml"})
if resp.status_code == 401:
raise SyncError("caldav rejected credentials")
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return _parse_caldav_events(resp.text)
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
url = (creds.get("url", "") or "").rstrip("/")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
href = f"{url}/{uid}.ics" if not remote_id else (
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
event.get("start", ""), event.get("description", ""))
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return uid
# ── mapping + sync ─────────────────────────────────────────────────────────
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
props = conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()
if wanted:
for p in props:
if str(p["id"]) == str(wanted) or p["name"] == wanted:
return str(p["id"]), p["name"]
return None
for p in props:
# prop_type lives in the row; fetch full rows only when needed
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
(p["id"],)).fetchone()
if full and full["prop_type"] == "date":
return str(p["id"]), p["name"]
return None
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
raw = values.get(prop_id, values.get(prop_name, ""))
if isinstance(raw, dict):
raw = raw.get("date") or raw.get("value") or ""
return str(raw or "")
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
text = str(value).strip()
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()
except Exception: # noqa: BLE001
try:
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
except Exception: # noqa: BLE001
return 0.0
def _window() -> tuple[str, str]:
now = datetime.now(UTC)
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
return start, end
async def sync_link(link_id: int) -> dict:
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
link = _load_link(link_id)
if not link:
raise ValueError("link not found")
creds = _decrypt_tokens(link.get("tokens_enc") or "")
collection_id = link.get("collection_id")
if not collection_id:
raise ValueError("link has no collection")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
tmin, tmax = _window()
if link["provider"] == "google":
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
tmin, tmax)
else:
remote = await caldav_list_events(creds, tmin, tmax)
last_sync = _to_epoch(link.get("last_sync"))
pulled = pushed = conflicts = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, property_values_json, updated_at,"
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchall()
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
seen_remote: set[str] = set()
touched: set[int] = set() # rows written by this pull pass — never push back
for ev in remote:
eid = ev.get("id", "")
if not eid:
continue
seen_remote.add(eid)
day = (ev.get("start") or "")[:10]
if eid not in local:
values: dict = {}
values[prop_id] = day
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, position, property_values_json, external_event_id)
VALUES (?,?,?,?,?)""",
(collection_id, ev.get("title") or "Untitled", max_pos,
json.dumps(values), eid))
pulled += 1
continue
row = local[eid]
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
local_day = _row_date(values, prop_id, prop_name)[:10]
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
if remote_newer and local_dirty and local_day and day and local_day != day:
# Conflict: both sides moved → last-write-wins + notify.
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
conflicts += 1
_notify_conflict(conn, link, row, ev)
elif day and day != local_day:
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
pulled += 1
# Push local changes (created locally or edited after last_sync).
for xid, row in local.items():
if row["id"] in touched:
continue
if xid in seen_remote:
# Edited locally since last sync and remote unchanged → push.
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
await _push(link, creds, row, prop_id, prop_name, xid)
pushed += 1
continue
# Remote deleted the event → drop the local id (keep the row).
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
(row["id"],))
# Rows never linked and recently touched → create remotely.
fresh = conn.execute(
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
(collection_id,)).fetchall()
for row in fresh:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
day = _row_date(values, prop_id, prop_name)[:10]
if not day:
continue
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
(new_id, row["id"]))
pushed += 1
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
(link_id,))
conn.commit()
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
prop_name: str, remote_id: str) -> str:
try:
values = json.loads(row.get("property_values_json") or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
event = {"title": row.get("title") or "Untitled",
"start": _row_date(values, prop_id, prop_name),
"description": ""}
if link["provider"] == "google":
return await google_push_event(creds, link.get("calendar_id") or "primary",
event, remote_id)
return await caldav_push_event(creds, event, remote_id)
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
try:
from app.services.notifications import create_notification
create_notification(
link["user_id"], link["user_id"], "calendar",
"Calendar sync conflict",
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
resource_type="collection", resource_id=link.get("collection_id") or 0,
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
except Exception: # noqa: BLE001 — notify must never break sync
pass
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
"""Background loop: sync every link with a collection (15 min default)."""
while True:
try:
with get_conn() as conn:
ids = [r["id"] for r in conn.execute(
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
).fetchall()]
for link_id in ids:
try:
await sync_link(link_id)
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
logger.debug("calendar sync link %s failed: %s", link_id, exc)
except Exception as exc: # noqa: BLE001
logger.warning("calendar_sync_scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── free/busy ──────────────────────────────────────────────────────────────
def freebusy(collection_id: int, date_from: str, date_to: str,
date_property: str = "") -> dict:
"""Busy/free weekdays in [date_from, date_to] (day granularity).
Expands recurrence rules server-side (``recurrence.expand_rule``).
"""
from app.services import recurrence as _rec
try:
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
except ValueError:
raise ValueError("use YYYY-MM-DD dates") from None
if end < start or (end - start).days > 370:
raise ValueError("range must be 1..370 days")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, date_property)
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
busy: set[str] = set()
for r in rows:
try:
values = json.loads(r["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
continue
base = _row_date(values, prop_id, prop_name)
if not base:
continue
rec = (values.get("__recurrence__") or {})
rule = rec.get(prop_id) or rec.get(prop_name)
if rule:
try:
for occ in _rec.expand_rule(
base, rule, start.isoformat(), end.isoformat()):
busy.add(occ[:10])
except Exception: # noqa: BLE001 — bad rule, use base date only
busy.add(base[:10])
else:
if start.isoformat() <= base[:10] <= end.isoformat():
busy.add(base[:10])
days, free = [], []
day = start
while day <= end:
iso = day.isoformat()
days.append({"date": iso, "busy": iso in busy,
"weekend": day.weekday() >= 5})
if iso not in busy and day.weekday() < 5:
free.append(iso)
day += timedelta(days=1)
return {"collection_id": collection_id, "from": start.isoformat(),
"to": end.isoformat(), "days": days, "free_weekdays": free}
+6 -2
View File
@@ -17,12 +17,12 @@ from __future__ import annotations
import io
import json
import os
import re
import zipfile
from pathlib import Path
from urllib.parse import quote
from app.config import settings
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
def _data_root() -> Path:
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _file_meta(page: dict) -> dict:
@@ -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"))
+31 -1
View File
@@ -35,7 +35,10 @@ class GiteaClient:
return None
def _set_cache(self, key: str, value: Any) -> None:
self._cache[key] = (datetime.now() + self._ttl, value)
now = datetime.now()
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
self._cache[key] = (now + self._ttl, value)
# ── repos ──
@@ -73,6 +76,33 @@ class GiteaClient:
self._set_cache(cache_key, data)
return data
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata for an unfurl card (owner/name/branch/…)."""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name") or f"{owner}/{repo}",
"clone_url": info.get("clone_url", ""),
"html_url": info.get("html_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
}
self._set_cache(cache_key, repo_info)
return repo_info
async def get_user_orgs(self) -> list[dict]:
cache_key = "user_orgs"
cached = self._cached(cache_key)
+1 -1
View File
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
logger.exception("get_repo_info")
self._set_cache(cache_key, repo_info)
return repo_info
-6
View File
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
+2 -2
View File
@@ -10,11 +10,11 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from app.config import settings
from app.db import get_conn
from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
return Path(settings.data_dir)
def _safe_filename(name: str) -> str:
+113
View File
@@ -0,0 +1,113 @@
"""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.config import settings
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(settings.data_dir)
d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True)
return d
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
"""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
"no transcription backend (set STT_COMMAND or POST a manual transcript)")
if shutil.which(cmd_template.split()[0]) is None:
raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}")
try:
proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured
capture_output=True, text=True, timeout=600)
except subprocess.TimeoutExpired as exc:
raise TranscriptionUnavailable("transcription timed out") from exc
text = (proc.stdout or "").strip()
if proc.returncode != 0 or not text:
raise TranscriptionUnavailable(
f"transcription failed: {(proc.stderr or '')[:300]}")
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
Returns {transcript_id, summary, offline}. Emits the automation event so
custom agents (update tracker, post recap, file tickets) trigger.
"""
from app.services.ai_writing import AIWritingService
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
raise ValueError("transcript is empty — transcribe first")
svc = AIWritingService(user_id=user_id)
res = await svc.run("summarize", context=tr["transcript"])
summary = (res.get("text") or "").strip() if res.get("ok") else ""
if not summary:
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
(summary, transcript_id))
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
+33 -3
View File
@@ -103,6 +103,34 @@ def parse_og(body: str, url: str) -> dict:
}
_MAX_REDIRECTS = 5
async def _get_checked(client, url: str, headers: dict):
"""GET avec re-vérification de l'hôte à CHAQUE saut de redirection (A12 SSRF).
`follow_redirects=True` laisserait une URL publique rediriger vers
169.254.169.254 / localhost — la garde doit donc tourner à chaque hop.
"""
from app.services.importers.url_fetch import _is_public_host
current = url
for _ in range(_MAX_REDIRECTS + 1):
parsed = urlparse(current)
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
raise ValueError(f"hôte non autorisé: {parsed.hostname!r}")
r = await client.get(current, headers=headers, follow_redirects=False)
if r.status_code in (301, 302, 303, 307, 308):
loc = r.headers.get("location")
if not loc:
return r
current = urljoin(current, loc)
continue
r.raise_for_status()
return r
raise ValueError("trop de redirections")
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors.
@@ -121,12 +149,14 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
kwargs = {"follow_redirects": True, "timeout": timeout}
kwargs = {"timeout": timeout}
if transport is not None:
kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client:
resp = await client.get(src, headers=headers)
resp.raise_for_status()
resp = await _get_checked(client, src, headers)
except ValueError:
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
raise
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
logger.debug("og fetch failed for %s: %s", src, exc)
base["title"] = urlparse(src).netloc or src
+4 -30
View File
@@ -96,6 +96,10 @@ PROPERTY_TYPES: dict[str, dict] = {
"storage": "auto — {id, login}",
"default": None,
},
"button": {
"storage": "none — runs linked automation (button_automation_id)",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
@@ -243,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
return None
def apply_auto_properties(
@@ -287,28 +284,5 @@ def apply_auto_properties(
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+91
View File
@@ -0,0 +1,91 @@
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
Les trois surfaces divergeaient avant cette passe :
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
donc pas un partage manuel.
"""
from __future__ import annotations
import logging
import re
import secrets
import unicodedata
from fastapi import HTTPException
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
def slugify(title: str) -> str:
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug
def _unique_slug(conn, page_id: int, title: str) -> str:
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
slug, counter = base, 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base}-{counter}"
counter += 1
return slug
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
with get_conn() as conn:
page = conn.execute(
"SELECT id, title FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
)
conn.commit()
return slug, page["title"] or ""
def unpublish(page_id: int) -> None:
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
)
conn.commit()
async def fire_published(page_id: int, slug: str) -> None:
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish page.published")
async def fire_unpublished(page_id: int) -> None:
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("publish page.unpublished")
+1 -1
View File
@@ -349,7 +349,7 @@ class RealtimeManager:
try:
await conn.ws.close(code=4413)
except Exception:
pass
logger.exception("_evict_slow")
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
-3
View File
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
+535
View File
@@ -0,0 +1,535 @@
"""FlowDeck — semantic (vector) search + Ask AI (v6.9.0).
Hybrid retrieval = lexical (FTS5/LIKE via :mod:`app.services.search`) fused
with vector cosine similarity via Reciprocal Rank Fusion, then filtered
through :class:`PermissionManager` so unauthorized chunks never surface
(and never enter an LLM prompt).
Vectors use a dependency-free **hashed TF** encoder (``hash-256``): token →
``md5 % 256`` with L2 normalization. Deterministic, offline-first, good
enough for recall on small workspaces; the ``embed_texts`` entry point is
pluggable should an LLM ``/embeddings`` provider be wired later.
See ``docs/V69_Search_Ask_AI.md``.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import math
import re
import struct
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
DIM = 256
MODEL = "hash-256"
CHUNK_SIZE = 1200
CHUNK_OVERLAP = 150
MAX_CHUNKS_PER_RESOURCE = 50
RRF_K = 60
_TOKEN_RE = re.compile(r"[\wÀ-ÿ]+", flags=re.UNICODE)
# Ask cache: (question_hash, workspace_id, user_id) -> (expires_at, payload)
_ask_cache: dict[tuple[str, int | None, int], tuple[float, dict]] = {}
_ASK_CACHE_TTL = 600.0
# Ask rate limit: user_id -> (window_start, count)
_ask_rate: dict[int, tuple[float, int]] = {}
_ASK_RATE_MAX = 30
_ASK_RATE_WINDOW = 60.0
# ── text extraction & chunking ─────────────────────────────────────────────
def _blocks_to_text(blocks) -> list[str]:
parts: list[str] = []
def _walk(items) -> None:
for b in items or []:
if not isinstance(b, dict):
continue
for key in ("content", "text", "title"):
val = b.get(key)
if isinstance(val, str) and val.strip():
parts.append(val.strip())
break
children = b.get("children")
if isinstance(children, list):
_walk(children)
_walk(blocks if isinstance(blocks, list) else [])
return parts
def extract_page_text(content: str | None, content_format: str | None) -> str:
"""Full searchable text of a ``pages`` row (all blocks, recursive)."""
if not content:
return ""
if (content_format or "blocks") == "blocks":
try:
blocks = json.loads(content)
return "\n".join(_blocks_to_text(blocks))
except Exception:
return content
return content
def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
"""Split text into overlapping chunks (char-based, word-boundary aware)."""
text = (text or "").strip()
if not text:
return []
if len(text) <= size:
return [text]
chunks: list[str] = []
start = 0
while start < len(text):
end = min(start + size, len(text))
if end < len(text):
space = text.rfind(" ", start, end)
if space > start + size // 2:
end = space
chunks.append(text[start:end].strip())
if end >= len(text):
break
start = max(end - overlap, start + 1)
if len(chunks) >= MAX_CHUNKS_PER_RESOURCE:
break
return [c for c in chunks if c]
# ── hashed-TF embeddings ───────────────────────────────────────────────────
def _tokens(text: str) -> list[str]:
return [t.lower() for t in _TOKEN_RE.findall(text or "") if t]
def embed_text(text: str, dim: int = DIM) -> bytes:
"""Deterministic L2-normalized hashed-TF vector, struct-packed float32."""
vec = [0.0] * dim
for tok in _tokens(text):
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim
vec[idx] += 1.0
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
return struct.pack(f"<{dim}f", *vec)
def embed_texts(texts: list[str], dim: int = DIM) -> list[bytes]:
"""Batch entry point (pluggable: LLM /embeddings can replace hashing)."""
return [embed_text(t, dim) for t in texts]
def cosine(a: bytes, b: bytes, dim: int = DIM) -> float:
"""Cosine similarity of two packed normalized vectors (== dot product)."""
try:
va = struct.unpack(f"<{dim}f", a)
vb = struct.unpack(f"<{dim}f", b)
except struct.error:
return 0.0
return sum(x * y for x, y in zip(va, vb, strict=True))
# ── indexing ───────────────────────────────────────────────────────────────
def _resource_text(conn, resource_type: str, resource_id: int) -> str | None:
"""Return indexable text, or None when the resource must not be indexed."""
if resource_type == "page":
row = conn.execute(
"SELECT title, content, content_format FROM pages "
"WHERE id=? AND (deleted_at IS NULL OR deleted_at='') "
"AND COALESCE(search_excluded, 0)=0",
(resource_id,),
).fetchone()
if not row:
return None
body = extract_page_text(row["content"], row["content_format"])
return f"{row['title'] or ''}\n{body}".strip()
if resource_type == "collection":
row = conn.execute(
"SELECT name, description FROM collections WHERE id=?", (resource_id,)
).fetchone()
if not row:
return None
return f"{row['name'] or ''}\n{row['description'] or ''}".strip()
return None
def index_resource(resource_type: str, resource_id: int) -> int:
"""(Re)index one resource. Returns the number of chunks stored."""
with get_conn() as conn:
text = _resource_text(conn, resource_type, resource_id)
conn.execute(
"DELETE FROM semantic_embeddings WHERE resource_type=? AND resource_id=?",
(resource_type, resource_id),
)
n = 0
if text:
for i, chunk in enumerate(chunk_text(text)[:MAX_CHUNKS_PER_RESOURCE]):
conn.execute(
"""INSERT INTO semantic_embeddings
(resource_type, resource_id, chunk_id, chunk_text, embedding, model)
VALUES (?, ?, ?, ?, ?, ?)""",
(resource_type, resource_id, i, chunk, embed_text(chunk), MODEL),
)
n += 1
conn.execute(
"""INSERT INTO semantic_index_state (resource_type, resource_id, indexed_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(resource_type, resource_id)
DO UPDATE SET indexed_at=CURRENT_TIMESTAMP""",
(resource_type, resource_id),
)
conn.commit()
return n
def _stale_resources(conn, limit: int) -> list[tuple[str, int]]:
out: list[tuple[str, int]] = []
rows = conn.execute(
"""SELECT p.id, p.updated_at FROM pages p
LEFT JOIN semantic_index_state s
ON s.resource_type='page' AND s.resource_id=p.id
WHERE (p.deleted_at IS NULL OR p.deleted_at='')
AND COALESCE(p.search_excluded, 0)=0
AND (s.indexed_at IS NULL OR p.updated_at > s.indexed_at)
ORDER BY p.updated_at DESC LIMIT ?""",
(limit,),
).fetchall()
out += [("page", r["id"]) for r in rows]
if len(out) < limit:
rows = conn.execute(
"""SELECT c.id, c.updated_at FROM collections c
LEFT JOIN semantic_index_state s
ON s.resource_type='collection' AND s.resource_id=c.id
WHERE s.indexed_at IS NULL OR c.updated_at > s.indexed_at
ORDER BY c.updated_at DESC LIMIT ?""",
(limit - len(out),),
).fetchall()
out += [("collection", r["id"]) for r in rows]
return out
def purge_orphans() -> int:
"""Drop vectors for deleted/excluded resources. Returns rows removed."""
with get_conn() as conn:
cur = conn.execute(
"""DELETE FROM semantic_embeddings
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.execute(
"""DELETE FROM semantic_index_state
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.commit()
return cur.rowcount or 0
def index_pending(limit: int = 50) -> dict:
"""Index up to ``limit`` stale resources + purge orphans (scheduler job)."""
with get_conn() as conn:
stale = _stale_resources(conn, limit)
indexed = 0
for rtype, rid in stale:
try:
index_resource(rtype, rid)
indexed += 1
except Exception as exc: # never break the scheduler loop
logger.debug("semantic index failed for %s %s: %s", rtype, rid, exc)
purged = purge_orphans()
return {"checked": len(stale), "indexed": indexed, "purged": purged}
async def semantic_index_scheduler(interval_seconds: int = 300) -> None:
"""Background task: incremental indexing (wired in app lifespan)."""
while True:
try:
await asyncio.to_thread(index_pending)
except Exception as exc: # noqa: BLE001 — scheduler must survive
logger.debug("semantic index scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── vector search ──────────────────────────────────────────────────────────
def vector_search(query: str, *, limit: int = 20,
resource_types: tuple[str, ...] = ("page", "collection")) -> list[dict]:
"""Brute-force cosine scan (fine at this scale). Returns ranked chunks."""
q = (query or "").strip()
if not q:
return []
qvec = embed_text(q)
with get_conn() as conn:
placeholders = ",".join("?" for _ in resource_types)
rows = conn.execute(
f"""SELECT resource_type, resource_id, chunk_id, chunk_text
FROM semantic_embeddings WHERE resource_type IN ({placeholders})""",
list(resource_types),
).fetchall()
scored = []
for r in rows:
row = conn.execute(
"SELECT embedding FROM semantic_embeddings "
"WHERE resource_type=? AND resource_id=? AND chunk_id=?",
(r["resource_type"], r["resource_id"], r["chunk_id"]),
).fetchone()
s = cosine(qvec, row["embedding"]) if row else 0.0
if s > 0:
scored.append({
"resource_type": r["resource_type"],
"resource_id": r["resource_id"],
"chunk_id": r["chunk_id"],
"chunk_text": r["chunk_text"],
"score": s,
})
scored.sort(key=lambda d: d["score"], reverse=True)
return scored[:limit]
# ── hybrid (lexical + vector, RRF) + ACL ───────────────────────────────────
def _rrf_fuse(ranked_lists: list[list[tuple[str, int]]], k: int = RRF_K) -> list[tuple[str, int, float]]:
scores: dict[tuple[str, int], float] = {}
for ranked in ranked_lists:
for rank, key in enumerate(ranked):
scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1)
fused = [(t, i, s) for (t, i), s in scores.items()]
fused.sort(key=lambda x: x[2], reverse=True)
return fused
def hybrid_search(query: str, user: dict, *, limit: int = 20,
workspace_id: int | None = None,
resource_types: tuple[str, ...] = ("page", "collection")) -> tuple[list[dict], int]:
"""Lexical + vector fusion, workspace-scoped, ACL-filtered.
Returns (results, total). Each result: {type, id, title, excerpt, url, score}.
"""
from app.services.permission_manager import PermissionManager
q = (query or "").strip()
if not q:
return [], 0
limit = max(1, min(int(limit or 20), 100))
user_id = user.get("id")
pm = PermissionManager(user_id, bool(user.get("is_admin")))
# 1) lexical candidates (already workspace-membership scoped)
from app.services import search as search_service
lex = search_service.search(q, user_id, limit * 3)
lex_ranked: list[tuple[str, int]] = []
lex_by_key: dict[tuple[str, int], dict] = {}
for item in (lex.get("pages") or []) + (lex.get("collections") or []):
key = (item["type"], item["id"])
if key not in lex_by_key:
lex_by_key[key] = item
lex_ranked.append(key)
# 2) vector candidates
vec = vector_search(q, limit=limit * 3, resource_types=resource_types)
vec_ranked = [(d["resource_type"], d["resource_id"]) for d in vec]
# 3) fuse
fused = _rrf_fuse([lex_ranked, vec_ranked])
# 4) ACL + workspace filter, enrich
results: list[dict] = []
with get_conn() as conn:
for rtype, rid, score in fused:
if rtype == "page":
if not pm.can_view_page(rid):
continue
row = conn.execute(
"SELECT id, title, content, content_format, workspace_id, "
"COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (rid,)).fetchone()
if not row or row["excluded"]:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("excerpt")
or extract_page_text(row["content"], row["content_format"])[:160])
results.append({"type": "page", "id": rid,
"title": (row["title"] or "Untitled"),
"excerpt": excerpt, "url": f"/pages/{rid}",
"score": round(score, 5)})
else:
if not pm.can_view_collection(rid):
continue
row = conn.execute(
"SELECT id, name, description, workspace_id FROM collections WHERE id=?",
(rid,)).fetchone()
if not row:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("subtitle")
or (row["description"] or "")[:160])
results.append({"type": "collection", "id": rid,
"title": (row["name"] or "Untitled"),
"excerpt": excerpt, "url": f"/db/{rid}",
"score": round(score, 5)})
if len(results) >= limit:
break
return results, len(results)
# ── Ask AI ─────────────────────────────────────────────────────────────────
def _check_ask_rate(user_id: int) -> None:
from fastapi import HTTPException
now = time.time()
start, count = _ask_rate.get(user_id, (now, 0))
if now - start > _ASK_RATE_WINDOW:
_ask_rate[user_id] = (now, 1)
return
if count >= _ASK_RATE_MAX:
raise HTTPException(429, "Too many questions. Slow down.")
_ask_rate[user_id] = (start, count + 1)
def _offline_answer(question: str, chunks: list[dict]) -> str:
"""Extractive fallback: top sentences sharing query terms + citations."""
qterms = {t.lower() for t in _tokens(question)}
picked: list[str] = []
for ch in chunks[:8]:
for sent in re.split(r"(?<=[.!?])\s+", ch["chunk_text"] or ""):
words = {t.lower() for t in _tokens(sent)}
if qterms & words and len(sent.strip()) > 20:
picked.append((sent.strip(), ch))
if len(picked) >= 4:
break
if len(picked) >= 4:
break
if not picked:
# No lexical overlap: still cite the top vector matches.
lines = []
for ch in chunks[:3]:
snippet = (ch["chunk_text"] or "")[:200].replace("\n", " ")
lines.append(f"- {snippet} [[fdpage:{ch['resource_id']}]]"
if ch["resource_type"] == "page" else f"- {snippet}")
return ("Je n'ai pas trouvé de passage répondant directement, "
"mais voici les passages les plus proches :\n" + "\n".join(lines))
lines = []
for sent, ch in picked:
if ch["resource_type"] == "page":
lines.append(f"- {sent} [[fdpage:{ch['resource_id']}]]")
else:
lines.append(f"- {sent}")
return "Voici ce que j'ai trouvé dans votre workspace :\n" + "\n".join(lines)
def _resolve_citations(conn, chunks: list[dict]) -> list[dict]:
seen: list[dict] = []
done: set[tuple[str, int]] = set()
for ch in chunks:
key = (ch["resource_type"], ch["resource_id"])
if key in done:
continue
done.add(key)
if ch["resource_type"] == "page":
row = conn.execute("SELECT title FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
seen.append({"type": "page", "id": ch["resource_id"],
"title": (row["title"] if row else "Deleted page") or "Untitled"})
else:
row = conn.execute("SELECT name FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
seen.append({"type": "collection", "id": ch["resource_id"],
"title": (row["name"] if row else "Deleted") or "Untitled"})
return seen
async def ask(question: str, user: dict, workspace_id: int | None = None) -> dict:
"""RAG answer over the user's authorized chunks (LLM or offline fallback)."""
from app.services.permission_manager import PermissionManager
q = (question or "").strip()
if not q:
from fastapi import HTTPException
raise HTTPException(400, "question is required")
_check_ask_rate(user.get("id") or 0)
cache_key = (hashlib.sha256(q.encode()).hexdigest(), workspace_id, user.get("id"))
now = time.time()
hit = _ask_cache.get(cache_key)
if hit and hit[0] > now:
out = dict(hit[1])
out["cached"] = True
return out
pm = PermissionManager(user.get("id"), bool(user.get("is_admin")))
vec = vector_search(q, limit=24)
allowed = []
with get_conn() as conn:
for ch in vec:
if ch["resource_type"] == "page":
row = conn.execute(
"SELECT workspace_id, COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
if not row or row["excluded"] or not pm.can_view_page(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
else:
row = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
if not row or not pm.can_view_collection(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
allowed.append(ch)
if len(allowed) >= 8:
break
answer = ""
offline = True
if allowed:
try:
from app.services.llm_client import LLMClient
llm = LLMClient()
if await llm.is_available():
ctx = "\n\n".join(
f"[doc {i+1} page_id={c['resource_id']}]\n{c['chunk_text'][:1500]}"
for i, c in enumerate(allowed)
)
resp = await llm.complete([
{"role": "system",
"content": "Réponds en français en citant les sources avec "
"[[fdpage:ID]] (ID = page_id indiqué). Concis."},
{"role": "user", "content": f"Question : {q}\n\nContexte :\n{ctx}"},
])
answer = (resp.text or "").strip()
offline = False
except Exception as exc: # noqa: BLE001 — fall back to extractive
logger.debug("ask LLM failed, offline fallback: %s", exc)
if not answer:
answer = ("Aucun contenu accessible ne correspond à votre question."
if not allowed else _offline_answer(q, allowed))
with get_conn() as conn:
citations = _resolve_citations(conn, allowed[:8])
out = {"answer_markdown": answer, "citations": citations,
"offline": offline, "cached": False}
_ask_cache[cache_key] = (now + _ASK_CACHE_TTL, out)
return out
def reset_state() -> None:
"""Test helper: clear ask cache + rate limiter."""
_ask_cache.clear()
_ask_rate.clear()
+1 -1
View File
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
)
conn.commit()
except Exception:
pass
logger.exception("purge_stale_requests")
-11
View File
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
+2 -1
View File
@@ -17,6 +17,7 @@ import json
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid))
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.db import get_conn
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
+136
View File
@@ -0,0 +1,136 @@
"""FlowDeck — TOTP 2FA + backup codes (v7.2.0).
Secrets are Fernet-encrypted at rest (same construction as SSO secrets).
Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived
signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a
session. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import hashlib
import json
import secrets
from app.db import get_conn
BACKUP_CODE_COUNT = 10
def _fernet():
import base64
from cryptography.fernet import Fernet
from app.config import settings
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def is_enabled(user_id: int) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode())
except Exception: # noqa: BLE001
return False
def setup_secret(user_id: int) -> dict:
"""Create a new TOTP secret (not yet active until verified)."""
import pyotp
secret = pyotp.random_base32()
with get_conn() as conn:
row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone()
label = (row["email"] or row["login"]) if row else f"user{user_id}"
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck")
return {"secret": secret, "otpauth_url": uri}
def activate_secret(user_id: int, secret: str, code: str) -> list[str]:
"""Verify ``code`` against ``secret``; on success store + return backup codes."""
import pyotp
if not pyotp.TOTP(secret).verify(code, valid_window=1):
raise ValueError("invalid code")
codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)]
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?",
(_fernet().encrypt(secret.encode()).decode(),
json.dumps(hashes), user_id))
conn.commit()
return codes
def verify_code(user_id: int, code: str) -> bool:
"""Check a TOTP code or consume a backup code."""
code = (code or "").strip().replace(" ", "")
if not code:
return False
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode()
except Exception: # noqa: BLE001
return False
import pyotp
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
return True
# backup codes (single use)
try:
hashes = json.loads(row["totp_backup_hashes"] or "[]")
except (TypeError, json.JSONDecodeError):
hashes = []
digest = hashlib.sha256(code.encode()).hexdigest()
if digest in hashes:
hashes.remove(digest)
with get_conn() as conn:
conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?",
(json.dumps(hashes), user_id))
conn.commit()
return True
return False
def disable(user_id: int) -> None:
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'"
" WHERE id=?", (user_id,))
conn.commit()
def remaining_backup_codes(user_id: int) -> int:
with get_conn() as conn:
row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
try:
return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0
except (TypeError, json.JSONDecodeError):
return 0
# ── pending 2FA challenge (signed, 5 min) ──────────────────────────────────
def mint_pending(user_id: int) -> str:
from itsdangerous import URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
return ser.dumps({"user_id": user_id})
def redeem_pending(token: str, max_age: int = 300) -> int | None:
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
try:
payload = ser.loads(token, max_age=max_age)
return int(payload.get("user_id", 0)) or None
except (BadSignature, SignatureExpired, ValueError):
return None
-13
View File
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
def verify_device_token(device_id: str, token: str) -> dict | None:
"""Verify a device token, returns device row or None."""
thash = _hash_token(token)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
(device_id, thash),
).fetchone()
if row:
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
return dict(row)
return None
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
+415
View File
@@ -0,0 +1,415 @@
"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0).
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux
non-membres (404, comme une collection restricted).
- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut).
- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ;
``guest_shares`` (``/g/<token>``) ; ``page_views`` (compteurs journaliers).
"""
from __future__ import annotations
import datetime
import secrets
from app.db import get_conn
VERIFICATION_DAYS_DEFAULT = 90
# Roles, strongest first. Mirrors collection roles.
TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer")
_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))}
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _iso(dt: datetime.datetime) -> str:
return dt.replace(microsecond=0).isoformat()
# ── teamspaces ─────────────────────────────────────────────────────────────
def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None:
"""Explicit role, else workspace role, else ``None`` when unreachable."""
if not user_id:
return None
with get_conn() as conn:
ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?",
(teamspace_id,)).fetchone()
if not ts:
return None
row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, user_id)).fetchone()
if row:
return row["role"]
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if admin and admin["is_admin"]:
return "owner"
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
if owner and owner["owner_id"] == user_id:
return "owner"
if not ts["private"]:
# a public teamspace is still workspace-scoped: no workspace
# membership means no access (otherwise any logged-in account on
# the instance could read every public teamspace).
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ts["workspace_id"], user_id)).fetchone()
return member["role"] if member else None
return None
def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool:
return get_teamspace_role(user_id, teamspace_id) is not None
def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool:
role = get_teamspace_role(user_id, teamspace_id)
return role in ("owner", "editor")
def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]:
"""Teamspaces the user can see (private ones filtered out).
``workspace_id=None`` lists across every workspace (global sidebar).
"""
with get_conn() as conn:
if workspace_id is not None:
ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id)).fetchone()
if not ws_member:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(workspace_id,)).fetchone()
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if not owner or owner["owner_id"] != user_id:
if not (admin and admin["is_admin"]):
return []
rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name",
(workspace_id,)).fetchall()
else:
rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall()
out = []
for r in rows:
role = get_teamspace_role(user_id, r["id"])
if role is None:
continue
counts = conn.execute(
"""SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?)
+ (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""",
(r["id"], r["id"])).fetchone()
item = dict(r)
item["role"] = role
item["workspace_name"] = _workspace_name(conn, r["workspace_id"])
item["item_count"] = counts["n"]
out.append(item)
return out
def _workspace_name(conn, workspace_id: int) -> str:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
def teamspace_pages(teamspace_id: int) -> list[dict]:
"""Non-deleted pages belonging to a teamspace (title/URL order)."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, title, workspace_id, teamspace_id
FROM pages WHERE teamspace_id=? AND deleted_at IS NULL
ORDER BY title""", (teamspace_id,)).fetchall()]
def teamspace_collections(teamspace_id: int) -> list[dict]:
"""Databases belonging to a teamspace."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, name, icon FROM collections
WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()]
def create_teamspace(workspace_id: int, name: str, user_id: int,
description: str = "", private: bool = False) -> int:
with get_conn() as conn:
try:
cur = conn.execute(
"INSERT INTO teamspaces (workspace_id, name, description, private, created_by)"
" VALUES (?,?,?,?,?)",
(workspace_id, name.strip(), description[:400], 1 if private else 0, user_id))
except Exception as exc: # UNIQUE(workspace_id, name)
if "UNIQUE" in str(exc):
raise ValueError("A teamspace with this name already exists") from None
raise
conn.commit()
# the creator is owner
conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)"
" VALUES (?,?,'owner')", (cur.lastrowid, user_id))
conn.commit()
return cur.lastrowid
def teamspace_member_ids(teamspace_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM teamspace_members WHERE teamspace_id=?",
(teamspace_id,)).fetchall()]
# ── verified pages ─────────────────────────────────────────────────────────
def is_expired(row) -> bool:
if not row or not row["expires_at"]:
return False
try:
return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"])
except ValueError:
return False
def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT,
note: str = "") -> dict:
days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365))
expires = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_verifications (page_id, verified_by, note, expires_at)
VALUES (?,?,?,?)
ON CONFLICT(page_id) DO UPDATE SET
verified_by=excluded.verified_by, note=excluded.note,
verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""",
(page_id, user_id, note[:400], expires))
conn.commit()
return verification(page_id)
def verification(page_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"""SELECT v.*, u.full_name, u.login FROM page_verifications v
LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""",
(page_id,)).fetchone()
if not row:
return None
d = dict(row)
d["expired"] = is_expired(row)
d["active"] = not d["expired"]
return d
def unverify_page(page_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,))
conn.commit()
return bool(cur.rowcount)
def expiring_verifications(days: int = 7) -> list[dict]:
"""Verifications expiring within ``days`` (drives the owner notification).
``pages`` has no owner column in FlowDeck, so the reminder targets the user
who performed the verification.
"""
horizon = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
rows = conn.execute(
"""SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""",
(horizon,)).fetchall()
return [dict(r) for r in rows]
# ── follows ────────────────────────────────────────────────────────────────
def is_following(page_id: int, user_id: int) -> bool:
with get_conn() as conn:
return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone())
def toggle_follow(page_id: int, user_id: int) -> bool:
"""Returns the new state (True = now following)."""
with get_conn() as conn:
if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id))
conn.commit()
return False
conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
conn.commit()
return True
def ensure_follow(page_id: int, user_id: int, conn=None) -> bool:
"""Follow unless already following. Returns True when newly followed."""
if not user_id:
return False
def _run(c):
if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
return False
c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
return True
if conn is not None:
added = _run(conn)
conn.commit()
return added
with get_conn() as _c:
added = _run(_c)
_c.commit()
return added
def followers(page_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()]
# Rate-limit window for ``page.updated`` notifications: the block editor
# autosaves every ~1.5 s; without it followers would be spammed per keystroke.
_UPDATE_NOTIF_WINDOW_MIN = 10
def notify_followers_of_page_update(page_id: int, actor_id: int | None,
title: str = "") -> int:
"""Create a ``page.updated`` notification for every follower (except the
actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes.
Returns the number of notifications created."""
if not page_id:
return 0
from app.services.notifications import create_notification
with get_conn() as conn:
followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()
if not followed:
return 0
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
title = (title or (page["title"] if page else "") or "Untitled")
created = 0
for f in followed:
uid = f["user_id"]
if uid == actor_id:
continue
recent = conn.execute(
"""SELECT 1 FROM notifications
WHERE user_id=? AND resource_type='page' AND resource_id=?
AND ntype='page.updated'
AND created_at >= datetime('now', ?)""",
(uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone()
if recent:
continue
create_notification(
uid, actor_id, "page.updated",
title=f'"{title}" was updated',
message=f'Page "{title}" has been modified',
resource_type="page", resource_id=page_id,
url=f"/pages/{page_id}", conn=conn, commit=False,
)
created += 1
conn.commit()
return created
# ── comment reactions ──────────────────────────────────────────────────────
def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict:
"""Add or remove ``emoji``; returns the aggregated counts for the comment."""
emoji = (emoji or "").strip()[:16]
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone():
raise LookupError("Comment not found")
existing = conn.execute(
"SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?",
(comment_id, user_id, emoji)).fetchone()
if existing:
conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],))
conn.commit()
else:
conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)"
" VALUES (?,?,?)", (comment_id, user_id, emoji))
conn.commit()
return reactions(comment_id)
def reactions(comment_id: int) -> dict[str, dict]:
with get_conn() as conn:
rows = conn.execute(
"""SELECT emoji, COUNT(*) AS n,
GROUP_CONCAT(user_id) AS users
FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""",
(comment_id,)).fetchall()
return {r["emoji"]: {"count": r["n"],
"users": [int(u) for u in (r["users"] or "").split(",") if u]}
for r in rows}
# ── guest shares ───────────────────────────────────────────────────────────
def create_guest_share(page_id: int, email: str, role: str, created_by: int,
days: int | None = 30) -> dict:
if role not in ("viewer", "commenter"):
raise ValueError("role must be viewer or commenter")
token = f"g_{secrets.token_urlsafe(24)}"
expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at)
VALUES (?,?,?,?,?,?)""",
(page_id, email[:200], token, role, created_by, expires))
conn.commit()
return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?",
(cur.lastrowid,)).fetchone())
def resolve_guest_share(token: str) -> dict | None:
"""Active share for ``token``, or ``None`` (unknown / revoked / expired)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone()
if not row or row["revoked"]:
return None
if row["expires_at"]:
try:
if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]):
return None
except ValueError:
pass
return dict(row)
# ── page views ─────────────────────────────────────────────────────────────
def record_view(page_id: int, day: str | None = None) -> int:
day = day or _utcnow().strftime("%Y-%m-%d")
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_views (page_id, day, views) VALUES (?,?,1)
ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""",
(page_id, day))
conn.commit()
row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?",
(page_id, day)).fetchone()
return row["views"]
def view_stats(page_id: int, days: int = 30) -> dict:
days = max(1, min(int(days or 30), 365))
since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d")
with get_conn() as conn:
rows = conn.execute(
"SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day",
(page_id, since)).fetchall()
series = {r["day"]: r["views"] for r in rows}
# fill the gap so charts have no holes
out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1)
for _ in range(days):
key = cursor.strftime("%Y-%m-%d")
out.append({"day": key, "views": series.get(key, 0)})
cursor += datetime.timedelta(days=1)
return {"page_id": page_id, "days": days, "total": sum(series.values()),
"series": out}
+196
View File
@@ -0,0 +1,196 @@
"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress.
Server-side rendering so HTML/PDF export embeds real content (the editor
already has Prism + KaTeX client-side). Design §2 of
``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``mermaid`` : SVG via ``mmdc`` when installed, else a ``<pre>`` fallback
that mermaid.js can still render in the browser.
- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
falls back to readable plaintext.
- ``progress`` : ``{"rollup_ref": {collection_id, property_id}}`` → percent
bar computed with :class:`RollupEngine`.
"""
from __future__ import annotations
import html
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
# ── mermaid ────────────────────────────────────────────────────────────────
def mmdc_available() -> bool:
return shutil.which("mmdc") is not None
def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
"""Render mermaid source to an inline SVG, or ``None`` if unavailable.
Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
degrade to the code fallback.
"""
source = (source or "").strip()
if not source or not mmdc_available():
return None
with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
src = Path(tmp) / "diagram.mmd"
out = Path(tmp) / "diagram.svg"
src.write_text(source, encoding="utf-8")
try:
subprocess.run(
["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
capture_output=True, timeout=timeout, check=True)
except (subprocess.SubprocessError, OSError):
return None
if not out.exists():
return None
svg = out.read_text(encoding="utf-8", errors="replace")
# strip the XML prolog / doctype so the SVG can be inlined
svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
return svg or None
def render_mermaid(source: str) -> str:
"""Inline SVG when possible, else a mermaid-renderable code block."""
svg = mermaid_to_svg(source)
if svg:
return f'<figure class="mermaid-figure">{svg}</figure>'
return (f'<pre class="mermaid"><code class="language-mermaid">'
f'{html.escape(source or "")}</code></pre>')
# ── equations ──────────────────────────────────────────────────────────────
# Only characters that are meaningful inside a math expression are kept. Note
# that `<`, `>` and `\` are NOT allowed: they would let the source close the
# KaTeX delimiter early or inject markup into the exported HTML.
_EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]")
def sanitize_equation(source: str) -> str:
return _EQUATION_ALLOWED.sub("", source or "").strip()
def render_equation(source: str) -> str:
expr = sanitize_equation(source)
if not expr:
return ""
return (f'<span class="fd-equation" data-equation="{html.escape(expr)}">'
f'\\({html.escape(expr)}\\)</span>')
# ── progress ───────────────────────────────────────────────────────────────
def _rows_values(collection_id: int) -> list[dict]:
import json as _json
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
out = []
for r in rows:
try:
out.append(_json.loads(r["property_values_json"] or "{}"))
except ValueError:
out.append({})
return out
_DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed")
def _is_done(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in _DONE_TRUE
return False
def progress_value(rollup_ref: dict | None) -> dict:
"""Resolve ``{collection_id, done_property_id | property_id, func}`` to a
0..100 percent.
Aggregates directly over ``collection_pages.property_values_json`` (the
relation-based :class:`RollupEngine` needs a relation chain + a source
page, which a page-embedded progress bar does not have).
"""
from app.db import get_conn
if not rollup_ref or not rollup_ref.get("collection_id"):
return {"percent": None, "done": None, "total": None}
cid = rollup_ref["collection_id"]
values = _rows_values(cid)
total = len(values)
done_pid = rollup_ref.get("done_property_id")
if done_pid:
key = str(done_pid)
done = sum(1 for vals in values if _is_done(vals.get(key)))
if total:
return {"percent": round(done * 100 / total, 1), "done": done, "total": total}
return {"percent": 0.0, "done": 0, "total": 0}
pid = rollup_ref.get("property_id")
if not pid or not total:
return {"percent": None, "done": None, "total": total}
key = str(pid)
with get_conn() as conn:
prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND"
" collection_id=?", (pid, cid)).fetchone()
if not prop:
return {"percent": None, "done": None, "total": total}
func = (rollup_ref.get("func") or "count").lower()
if prop["prop_type"] in ("checkbox", "status", "select"):
done = sum(1 for vals in values if _is_done(vals.get(key)))
elif prop["prop_type"] in ("number", "formula", "rollup"):
nums = []
for vals in values:
v = vals.get(key)
try:
nums.append(float(v))
except (TypeError, ValueError):
continue
if not nums:
return {"percent": None, "done": None, "total": total}
done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs),
"max": max, "min": min}.get(func, len)(nums)
else:
done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {}))
if not total:
return {"percent": 0.0, "done": done, "total": 0}
return {"percent": round(min(float(done) * 100 / total, 100), 1),
"done": done, "total": total}
def render_progress(block: dict) -> str:
stats = progress_value(block.get("rollup_ref") or block)
pct = stats["percent"]
label = block.get("label") or "Progress"
if pct is None:
return (f'<div class="fd-progress" data-percent=""><div class="fd-progress-label">'
f'{html.escape(label)}: —</div></div>')
detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else ""
return (f'<div class="fd-progress" data-percent="{pct}">'
f'<div class="fd-progress-label">{html.escape(label)}: {pct}%'
f'{(" (" + html.escape(detail) + ")") if detail else ""}</div>'
f'<div class="fd-progress-bar"><div class="fd-progress-fill"'
f' style="width:{min(pct, 100)}%"></div></div></div>')
def render_block(block: dict) -> str:
"""Dispatch for the three v7.3 block types (used by export + preview API)."""
t = block.get("type")
if t == "mermaid":
return render_mermaid(block.get("content") or block.get("source") or "")
if t == "equation_inline":
return render_equation(block.get("content") or "")
if t == "progress":
return render_progress(block)
return ""
-5
View File
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+234
View File
@@ -0,0 +1,234 @@
"""FlowDeck — Workers lite (v7.0.0).
Custom Python snippets run on FlowDeck infrastructure: manual, on a cron
schedule, or shared across the team (fork). Parité Notion Workers (07/2026),
sans facturation : un budget journalier secondes/workspace fait office de
« credits dashboard ».
Sandbox (documenté, best-effort single-process) :
- AST blacklist : ``import os/sys/subprocess/socket``, ``open()``,
``exec/eval/compile``, attributs dunder.
- Pas de réseau, pas de FS ; builtins restreints (pas de ``__import__``).
- Timeout 30 s (thread + join), budget journalier ``daily_budget_s``.
- Seules API exposées : ``log()``, ``ctx`` (dict), ``result`` (dict out).
Voir ``docs/V70_Automations_Workers.md``.
"""
from __future__ import annotations
import ast
import asyncio
import io
import logging
import re
import time
from contextlib import redirect_stdout
from datetime import UTC, datetime
from app.db import get_conn
logger = logging.getLogger(__name__)
RUN_TIMEOUT_S = 30
MAX_CODE_CHARS = 20_000
MAX_LOG_CHARS = 10_000
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,60}$")
_FORBIDDEN_IMPORTS = {"os", "sys", "subprocess", "socket", "shutil",
"pathlib", "io", "asyncio", "threading", "multiprocessing"}
_FORBIDDEN_CALLS = {"open", "exec", "eval", "compile", "__import__"}
class WorkerRejected(ValueError):
"""Raised when worker code violates the sandbox policy."""
def validate_code(code: str) -> None:
"""AST lint of worker code. Raises WorkerRejected on violation."""
code = code or ""
if len(code) > MAX_CODE_CHARS:
raise WorkerRejected(f"code too long ({len(code)} > {MAX_CODE_CHARS})")
try:
tree = ast.parse(code)
except SyntaxError as exc:
raise WorkerRejected(f"syntax error: {exc}") from None
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
names = [a.name.split(".")[0] for a in node.names]
if getattr(node, "module", None):
names.append(str(node.module).split(".")[0])
for name in names:
if name in _FORBIDDEN_IMPORTS:
raise WorkerRejected(f"import forbidden: {name}")
elif isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in _FORBIDDEN_CALLS:
raise WorkerRejected(f"call forbidden: {func.id}()")
elif isinstance(node, ast.Attribute):
if isinstance(node.attr, str) and node.attr.startswith("__"):
raise WorkerRejected(f"dunder access forbidden: {node.attr}")
def _slugify(name: str) -> str:
import unicodedata
slug = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
return re.sub(r"[-\s]+", "-", slug).strip("-") or "worker"
def unique_slug(base: str, ignore_id: int | None = None) -> str:
slug, i = _slugify(base)[:60] or "worker", 1
with get_conn() as conn:
while conn.execute(
"SELECT id FROM workers WHERE slug=? AND id != COALESCE(?, -1)",
(slug, ignore_id)).fetchone():
i += 1
slug = f"{_slugify(base)[:55]}-{i}"
return slug
_SAFE_BUILTINS = {
"abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
"frozenset": frozenset, "int": int, "len": len, "list": list, "map": map,
"max": max, "min": min, "range": range, "reversed": reversed, "round": round,
"set": set, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"zip": zip, "print": print, "isinstance": isinstance, "type": type,
}
def _exec_code(code: str, ctx: dict) -> tuple[dict, str]:
"""Run validated code in a thread. Returns (result_dict, logs)."""
logs: list[str] = []
def _log(*args) -> None:
logs.append(" ".join(str(a) for a in args))
namespace = {"__builtins__": dict(_SAFE_BUILTINS),
"log": _log, "ctx": dict(ctx or {}), "result": {}}
buf = io.StringIO()
with redirect_stdout(buf):
exec(compile(code, "<worker>", "exec"), namespace) # noqa: S102 — sandboxed
printed = buf.getvalue()
if printed:
logs.append(printed)
result = namespace.get("result")
return result if isinstance(result, dict) else {}, "\n".join(logs)[:MAX_LOG_CHARS]
def daily_usage_s(workspace_id: int | None) -> float:
"""CPU seconds consumed today (UTC) by a workspace's workers."""
day = datetime.now(UTC).strftime("%Y-%m-%d")
with get_conn() as conn:
row = conn.execute(
"""SELECT COALESCE(SUM(wr.duration_ms), 0) FROM worker_runs wr
JOIN workers w ON w.id = wr.worker_id
WHERE date(wr.created_at) = date(?)
AND COALESCE(w.workspace_id, -1) = COALESCE(?, -1)""",
(day, workspace_id)).fetchone()
return (row[0] or 0) / 1000.0
def _save_run(worker_id: int, status: str, logs: str, duration_ms: int) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO worker_runs (worker_id, status, logs, duration_ms)"
" VALUES (?,?,?,?)", (worker_id, status, logs[:MAX_LOG_CHARS], duration_ms))
conn.commit()
return cur.lastrowid
def run_worker(worker_id: int, ctx: dict | None = None) -> dict:
"""Execute a worker synchronously (used by the router + cron loop).
Returns {status, run_id, duration_ms}. Never raises for user-code errors
(they become ``error`` runs); raises only when the worker is missing or
over budget (caller maps to 404/429).
"""
from fastapi import HTTPException
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
raise HTTPException(404, "Worker not found")
worker = dict(row)
validate_code(worker.get("code_py") or "")
used = daily_usage_s(worker.get("workspace_id"))
if used >= (worker.get("daily_budget_s") or 60):
_save_run(worker_id, "over_budget", f"daily budget exceeded ({used:.1f}s used)", 0)
raise HTTPException(429, "Worker daily budget exceeded")
outcome: dict = {}
def _target() -> None:
try:
result, logs = _exec_code(worker.get("code_py") or "", ctx or {})
outcome["result"] = result
outcome["logs"] = logs
except Exception as exc: # noqa: BLE001 — user code, recorded
outcome["error"] = f"{type(exc).__name__}: {exc}"
import threading
started = time.time()
thread = threading.Thread(target=_target, daemon=True)
thread.start()
thread.join(timeout=RUN_TIMEOUT_S)
duration_ms = int((time.time() - started) * 1000)
if thread.is_alive():
run_id = _save_run(worker_id, "timeout",
f"exceeded {RUN_TIMEOUT_S}s timeout", duration_ms)
return {"status": "timeout", "run_id": run_id, "duration_ms": duration_ms}
if "error" in outcome:
run_id = _save_run(worker_id, "error", outcome["error"], duration_ms)
return {"status": "error", "run_id": run_id,
"duration_ms": duration_ms, "error": outcome["error"]}
run_id = _save_run(worker_id, "ok", outcome.get("logs", ""), duration_ms)
return {"status": "ok", "run_id": run_id, "duration_ms": duration_ms,
"result": outcome.get("result", {})}
async def run_due_workers() -> int:
"""Fire workers whose ``schedule_cron`` is due (called from the 60s loop)."""
from app.services.automations import cron_due
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM workers WHERE schedule_cron IS NOT NULL AND schedule_cron != ''"
).fetchall()
fired = 0
for row in rows:
worker = dict(row)
with get_conn() as conn:
last = conn.execute(
"SELECT MAX(created_at) FROM worker_runs WHERE worker_id=?",
(worker["id"],)).fetchone()[0]
try:
if cron_due(worker["schedule_cron"] or "", last):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, run_worker, worker["id"], {})
fired += 1
except Exception as exc: # noqa: BLE001 — one worker must not kill the loop
logger.debug("worker %s cron failed: %s", worker["id"], exc)
return fired
def fork_worker(worker_id: int, user_id: int) -> dict:
"""Duplicate a shared worker for another user (Notion-style sharing)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
from fastapi import HTTPException
raise HTTPException(404, "Worker not found")
src = dict(row)
if not src.get("shared") and src.get("created_by") != user_id:
from fastapi import HTTPException
raise HTTPException(403, "Worker is not shared")
slug = unique_slug(f"{src['slug']}-fork")
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
shared, daily_budget_s, created_by)
VALUES (?,?,?,?,?,?,?,?)""",
(slug, src["workspace_id"], f"{src['name']} (fork)", src["code_py"],
"", 0, src["daily_budget_s"], user_id))
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "slug": slug, "status": "forked", "from": worker_id}
+1 -1
View File
@@ -15,7 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button>
{% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div>
</header>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -2
View File
@@ -10,7 +10,7 @@
############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
@@ -89,7 +89,7 @@
})();
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
</span>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+1 -1
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
+3 -3
View File
@@ -1,7 +1,7 @@
<script type="application/json" id="page-data">
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
{{ page_data | tojson }}
</script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* eslint-disable */
if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true;
@@ -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;}
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() {
return {
profile: { full_name: '', email: '' },
+10 -10
View File
@@ -204,7 +204,7 @@
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -456,7 +456,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
@@ -555,7 +555,7 @@
var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'},
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
return resp.json();
@@ -701,7 +701,7 @@
if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){
@@ -824,7 +824,7 @@
installGallerySkill(slug, icon, name){
var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
@@ -988,7 +988,7 @@
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1005,7 +1005,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -1027,7 +1027,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
@@ -1832,7 +1832,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1930,7 +1930,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+61 -18
View File
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -404,6 +407,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 +533,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">
@@ -762,7 +793,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
@@ -1098,6 +1129,7 @@
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
this.loadTeamspaces();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1157,6 +1189,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 +1353,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 +1373,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 +1404,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 +1423,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 +1465,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 +1475,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'
};
@@ -2089,7 +2132,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2167,7 +2210,7 @@
</div>
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2337,8 +2380,8 @@
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script data-cfasync="false">
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
@@ -2352,7 +2395,7 @@
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
+1 -1
View File
@@ -151,7 +151,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+3 -3
View File
@@ -45,7 +45,7 @@
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
contenteditable="true"
@blur="updateField('body', $event.target.innerHTML)">
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
</div>
<!-- Checklists -->
@@ -82,7 +82,7 @@
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
</div>
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
{{ comment.body|safe }}
{{ comment.body }}
</div>
</div>
</div>
@@ -98,7 +98,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+5 -5
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
@@ -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) {
+1 -1
View File
@@ -210,7 +210,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() {
return {
sources: [],
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
+3 -3
View File
@@ -456,7 +456,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function libraryPage() {
return {
tab: 'recents',
@@ -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) {
+19 -18
View File
@@ -480,7 +480,7 @@
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
return {
@@ -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) {}
@@ -2511,7 +2512,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
+84 -31
View File
@@ -810,35 +810,57 @@
<!-- Admin: Audit Log -->
<div x-show="activeSection==='admin-audit'">
<h2>Audit Log</h2>
<p class="section-desc">Login history and security events.</p>
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
<span style="flex:1;"></span>
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
</div>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Source</th>
<th>Actor</th>
<th>Action</th>
<th>Resource</th>
<th>IP</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
<template x-for="e in auditEntries" :key="e.id">
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
<td>
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
</td>
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
</tr>
</template>
<tr x-show="auditEntries.length === 0">
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
<tr x-show="!auditLogs.length && !auditLoading">
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
</tr>
</tbody>
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
</style>
</div>
<!-- Admin: Backups -->
@@ -1169,7 +1191,7 @@
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function settingsInit() {
return {
activeSection: 'account',
@@ -1203,6 +1225,7 @@ function settingsInit() {
adminUsers: [],
adminStats: {},
auditEntries: [],
auditLogs: [],
showCreateUser: false,
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
@@ -1314,7 +1337,7 @@ function settingsInit() {
var n = this.newTagName.trim();
if (!n) return;
var r = await fetch('/api/settings/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({name: n, color: this.newTagColor})
});
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
@@ -1322,7 +1345,7 @@ function settingsInit() {
async updateTagColor(id, color) {
await fetch('/api/settings/tags/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
await this.loadTags();
@@ -1330,7 +1353,7 @@ function settingsInit() {
async deleteTag(id) {
if (!confirm('Delete this tag?')) return;
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await this.loadTags();
},
@@ -1352,7 +1375,7 @@ function settingsInit() {
this.renamingTag = null; return;
}
await fetch('/api/settings/tags/' + tag.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
body: JSON.stringify({name: newName})
});
this.renamingTag = null;
@@ -1394,11 +1417,41 @@ function settingsInit() {
},
async loadAdminAudit() {
await this.loadAuditLogs();
},
// ── Unified Audit API (v7.3.0) ──
auditSource: 'all',
auditActor: '',
auditAction: '',
auditOffset: 0,
auditPageSize: 50,
auditLoading: false,
auditHasMore: false,
async loadAuditLogs(reset) {
if (reset !== false) { this.auditOffset = 0; }
this.auditLoading = true;
try {
var r = await this.adminFetch('/api/admin/audit?limit=200');
var d = await r.json();
this.auditEntries = d.entries || [];
} catch(e) { this.auditEntries = []; }
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
const r = await fetch('/api/v2/audit/logs?' + params.toString());
if (!r.ok) { throw new Error('HTTP ' + r.status); }
const d = await r.json();
const logs = d.logs || [];
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
this.auditHasMore = logs.length >= this.auditPageSize;
} catch(e) {
if (reset !== false) this.auditLogs = [];
this.auditHasMore = false;
}
this.auditLoading = false;
},
auditCsvUrl() {
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
return '/api/v2/audit/logs?' + params.toString();
},
async adminCreateUser() {
@@ -1595,7 +1648,7 @@ function settingsInit() {
try {
var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
});
var d = await r.json();
@@ -1624,7 +1677,7 @@ function settingsInit() {
if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1660,7 +1713,7 @@ function settingsInit() {
if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify(body)
});
var d = await r.json();
@@ -1686,7 +1739,7 @@ function settingsInit() {
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
try {
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
var d = await r.json();
if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
@@ -1846,7 +1899,7 @@ function settingsInit() {
if (!file) return;
var form = new FormData();
form.append('file', file);
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
if (r.ok) {
var d = await r.json();
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
@@ -1857,7 +1910,7 @@ function settingsInit() {
async selectAvatarColor(color) {
this.avatarColor = color;
var r = await fetch('/api/settings/avatar-color', {
method: 'POST', headers: {'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body: JSON.stringify({color: color})
});
if (r.ok) { this.avatarUrl = ''; }
@@ -2002,7 +2055,7 @@ function settingsInit() {
// ── v5.2.0 API tokens ──
async loadApiTokens() {
try {
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
var d = await r.json();
this.apiTokens = d.tokens || [];
} catch(e) { this.apiTokens = []; }
@@ -2012,7 +2065,7 @@ function settingsInit() {
if (!name) return;
try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d;

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