Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 | ||
|
|
5a537f5dc3 | ||
|
|
8ab6569974 | ||
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 | ||
|
|
1706ad1ee9 |
@@ -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/
|
||||
|
||||
@@ -17,3 +17,12 @@ dist/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
uv.lock
|
||||
|
||||
# A9 — jamais de DB ni de fichiers de test dans git
|
||||
*.db
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
e2e/node_modules/
|
||||
e2e/shots/
|
||||
e2e/test-results/
|
||||
|
||||
+398
@@ -1,5 +1,403 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
|
||||
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
|
||||
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
|
||||
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
|
||||
préfixes sortent d'`EXCLUDED_PATHS`
|
||||
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
|
||||
(interpolations Jinja neutralisées) — 0 échec avant/après
|
||||
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
|
||||
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
|
||||
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
|
||||
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
|
||||
|
||||
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
|
||||
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
|
||||
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
|
||||
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
|
||||
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
|
||||
`welcome.html` équipés du header
|
||||
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
|
||||
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
|
||||
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
|
||||
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
|
||||
route, pas le 403 du middleware) → suite **1026/1026**
|
||||
|
||||
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A16** — `_load_page_or_404` (4 exports) et les 2 routes pièce jointe
|
||||
(`/download`, `/file-content`) passent par session + `PermissionManager.can_view_page`
|
||||
→ 401 sans session, 404 hors ACL ; la lecture legacy `board.py` était déjà
|
||||
couverte par A7
|
||||
- Test `test_exports_and_attachments_require_auth` → suite **1026/1026**
|
||||
|
||||
## v7.3.3 (2026-09-30) — Audit sécurité : A12–A24 (SSRF, auth legacy, uploads, perf)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A12** — unfurl OG : `follow_redirects` manuel + `_is_public_host` à chaque
|
||||
saut → 400 vers loopback/link-local (ex. `169.254.169.254`)
|
||||
- **A13** — automations : `Depends(_require_session)` sur le router entier
|
||||
(CRUD, run, press-button) + action `webhook` validée avant POST
|
||||
- **A15** — webhooks sortants : admin exigé + URL publique (SSRF scheduler)
|
||||
- **A17** — router legacy `/api` : session ou Bearer (`/api/v1`) ; allowlist
|
||||
explicite `/api/health`, `/api/frontend-error`
|
||||
- **A22** — uploads locaux : session exigée, `validate_upload` branché (10 MB +
|
||||
extensions), `FLOWDECK_DATA_DIR` remplace le `/data` codé en dur
|
||||
- **A23** — N+1 : `GROUP BY` (compteurs de pages), `executemany` (cards de sync
|
||||
+ duplicata de propriétés avec remap d'ids vérifié)
|
||||
- **A24** — 2 routes silencieusement écrasées supprimées + test « aucun doublon
|
||||
méthode+chemin » sur les 680 routes
|
||||
|
||||
### Tests
|
||||
|
||||
- +9 non-régressions dans `tests/test_audit_p0_fixes.py` → suite **1025/1025**
|
||||
|
||||
## v7.3.2 (2026-09-30) — Audit sécurité : A11 + A18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A11** — `GET /api/settings/avatar/{filename:path}` : `resolve()` +
|
||||
`relative_to()` (le motif de `serve_uploaded_file`) → 403 hors `/data/avatars`
|
||||
- **A18** — `GET /workspace/public/{id}` : 404 explicite pour les bases
|
||||
`restricted`/`private` (`permission_type`) et `html.escape` sur nom, icône et
|
||||
titres de lignes — ce f-string HTML ne passe pas par Jinja2, donc l'autoescape
|
||||
A10 ne le couvrait pas
|
||||
- Tests : `tests/test_audit_p0_fixes.py` (3 non-régressions) — suite **1019/1019**
|
||||
|
||||
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
|
||||
|
||||
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
|
||||
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
|
||||
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
|
||||
cycle v6.8→v7.3 committé + tag `v7.3.0`
|
||||
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
|
||||
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
|
||||
sorti de la liste CSRF exemptée
|
||||
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
|
||||
chemin legacy `user_id=0` supprimé
|
||||
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
|
||||
global), placeholder user créé en `is_admin=0`
|
||||
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
|
||||
legacy single-user sur les collections)
|
||||
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
|
||||
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
|
||||
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
|
||||
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
|
||||
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
|
||||
rotation de `APP_SECRET_KEY`
|
||||
- **A10** — `app/templating.py` : un seul `ENV` avec
|
||||
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
|
||||
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
|
||||
en `|tojson`)
|
||||
|
||||
### Tests
|
||||
|
||||
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
|
||||
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
|
||||
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
|
||||
|
||||
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
|
||||
|
||||
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
|
||||
> expiration, guests sans compte, réactions, follows, analytics de page et
|
||||
> trois nouveaux blocs rendus côté serveur. Design : `docs/V73_Wiki_Teamspaces_Polish.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Teamspaces** — `app/services/wiki.py` + `app/routers/wiki.py` : `teamspaces`
|
||||
(`workspace_id`, `private`, `UNIQUE(workspace_id, name)`) et
|
||||
`teamspace_members` (rôles `owner`/`editor`/`commenter`/`viewer`) ; CRUD +
|
||||
listing par workspace, ajout/retour de membres ; `private=1` → **404** (pas
|
||||
403) pour les non-membres, comme les collections restricted ; un teamspace
|
||||
public reste cantonné au workspace (pas de `viewer` implicite pour un compte
|
||||
qui n'en est pas membre) ; `pages.teamspace_id` + `collections.teamspace_id`
|
||||
- **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`,
|
||||
`expires_at` 90 j par défaut, re-vérifier remplace) ; l'index
|
||||
`GET /api/v2/wiki/verified` exclut les badges expirés et les pages de
|
||||
teamspaces privés ; sweep `POST /api/v2/wiki/verify-expiry-sweep` (admin)
|
||||
notifie le vérificateur à J-7 (`page.verification_expiring`) ; la
|
||||
vérification exige un rôle editor/owner/admin, sinon 403
|
||||
- **Guests sans compte** — `guest_shares` (`token`, `role viewer|commenter`,
|
||||
`expires_at`, `revoked`) ; accès par `GET /g/{token}` **sans session**,
|
||||
enregistrement d'une vue, révocation idempotente ; page 404 HTML dédiée au
|
||||
lieu d'une redirection vers `/workspaces`
|
||||
- **Collab polish** — `comment_reactions` (agrégation par emoji + users,
|
||||
toggle), `page_follows` (toggle + followers), `page_views` (compteurs
|
||||
journaliers, séries sans trou via `view_stats`)
|
||||
- **Wiki Home** — `GET /api/v2/wiki/home` (teamspaces + verified + recents)
|
||||
- **Blocs** — `app/services/wiki_blocks.py` : `mermaid` (SVG inline si
|
||||
`mmdc` est installé, sinon `<pre class="mermaid">` rendu côté client),
|
||||
`equation_inline` (KaTeX, source sanitizée : `<`, `>`, `\` retirés pour
|
||||
empêcher la fermeture anticipée du délimiteur ou l'injection de balises),
|
||||
`progress` (agrégation directe sur `property_values_json` → barre %) ; les
|
||||
trois sont rendues par `export.blocks_to_html` (donc présentes dans
|
||||
l'export HTML/PDF) et exposées via `POST /api/v2/wiki/blocks/preview`
|
||||
- **Migration 29** — `teamspaces`, `teamspace_members`, `page_verifications`,
|
||||
`comment_reactions`, `page_follows`, `guest_shares`, `page_views` +
|
||||
colonnes `teamspace_id` sur `pages`/`collections`
|
||||
- **Sidebar teamspaces** — section `Teamspaces` dans `base.html` (état Alpine
|
||||
`teamspaces`/`loadTeamspaces`/`openTeamspace`, section ouverte par défaut,
|
||||
icône/label/ordre, fallback du panneau de personnalisation) →
|
||||
`GET /api/v2/wiki/teamspaces` accepte désormais l'omission de `workspace_id`
|
||||
(listing cross-workspace avec `workspace_name`), page HTML
|
||||
`GET /wiki/teamspaces/{id}` (pages + collections du teamspace, rôle affiché) ;
|
||||
entrée `teamspaces` dans `sidebar_config.DEFAULT_CONFIG`
|
||||
- **Notif `page.updated` aux followers** — `wiki.notify_followers_of_page_update`
|
||||
(une par 10 min, `actor_id` exclu) branchée dans `automations.fire_event` ;
|
||||
payloads `actor_id` ajoutés dans `board.update_page` et `board.save_page_blocks` ;
|
||||
commenter une page = suivre (auto-follow `wiki.ensure_follow`, défaut ON)
|
||||
- **Charts avancés** — type `number` (KPI) avec agrégats `count|sum|avg|min|max`
|
||||
dans `collections._render_chart` (+ `_chart_aggregate`/`_fmt_number`) ; le « 0 »
|
||||
n'est plus forcé à 1 ; les dashboards multi-DB se rendent via
|
||||
`GET /db/{collection_id}/dashboards/{dashboard_id}` (widgets `collection_id`,
|
||||
≤ 40 widgets, ≤ 200 lignes/chart) ; `view_collection` choisit maintenant la
|
||||
config de vue correspondant au `view_type` demandé
|
||||
- **Unfurl `gitea:`/`github:`** — `POST /board/api/og/metadata` résout les refs
|
||||
`gitea:owner/repo` / `github:owner/repo` via `GiteaClient.get_repo_info`
|
||||
(ajouté) ou `GitHubAdapter` (token optionnel, sinon API publique) sans
|
||||
télécharger la page ; champs bookmark `url/title/description/image/site_name`
|
||||
conservés dans l'autosave du bloc
|
||||
- **UI Settings → Audit** — `settings.html` : l'onglet `admin-audit` interroge
|
||||
désormais `/api/v2/audit/logs` (sources `api`/`permissions`/`sso`, filtres
|
||||
`actor`/`action`, pagination « Load more », export CSV)
|
||||
- **SSO 21 casses** — dépendances `python3-saml==1.16.0` + `authlib==1.8.0`
|
||||
(plus `xmlsec`/`isodate`/`joserfc`) installées → `test_v67_sso.py` **38/38**
|
||||
- **Sécurité** — `sanitize_equation` retire désormais `<`/`>`/`\` (pas de
|
||||
breakout KaTeX/markup), `revoke_guest` 404 fondé sur l'existence, pas le
|
||||
rowcount
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v73_wiki_polish.py` : **58 → 72 tests** (sidebar cross-workspace +
|
||||
page teamspace owner/outsider, auto-follow par commentaire, notif
|
||||
`page.updated` throttlée + acteur exclu, `ensure_follow` idempotent, regex +
|
||||
unfurl gitea/github + endpoint `/board/api/og/metadata`, KPI + `_chart_values`
|
||||
(0 conservé) + dashboards multi-DB + 404)
|
||||
- `ruff check app tests` OK · suite complète `python -m pytest -n auto` :
|
||||
**1016 passed** (était : 981 passed / 21 failed en SSO avant install des deps)
|
||||
|
||||
---
|
||||
|
||||
## v7.2.0 (2026-09-29) — Enterprise admin : SCIM 2.0, 2FA, Audit, gouvernance agents
|
||||
|
||||
> Le socle administration d'une instance auto-hébergée en équipe : provisionnement
|
||||
> SCIM depuis l'IdP, TOTP + passkeys, journal d'audit unifié et garde-fous
|
||||
> d'exécution pour les agents. Design : `docs/V72_Enterprise_SCIM_2FA.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **SCIM 2.0** — `app/routers/scim.py` : `GET/POST /scim/v2/Users`,
|
||||
`GET/PUT/PATCH/DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, schémas
|
||||
core:2.0:User, `active` → `users.is_active` + révocation `user_sessions`,
|
||||
`Operations` PATCH `active`/`userName`) ; tokens SHA-256 stockés, affichés
|
||||
une seule fois, révocables (`/api/v2/scim/tokens`) ; exempté CSRF (clients
|
||||
IdP sans cookie) et 404 `application/scim+json` au lieu d'une redirection
|
||||
- **TOTP 2FA** — `app/services/two_factor.py` : secret chiffré Fernet au repos,
|
||||
10 codes de secours à usage unique (SHA-256), Défi `pending` signé 5 min ;
|
||||
`POST /auth/local-login` renvoie `2fa_required` sans créer de session, puis
|
||||
`POST /auth/local-verify` l'échange contre une session ; setup/activate/
|
||||
disable/status côté utilisateur
|
||||
- **Passkeys WebAuthn** — `app/routers/webauthn.py` : enregistrement
|
||||
(`register/begin|finish`, attestation vérifiée, COSE stocké) et connexion
|
||||
**sans mot de passe** (`login/begin|finish`, anti-rejeu `sign_count`,
|
||||
vérif. origine/RP) ; listing + suppression des clés
|
||||
- **Domain claims** — `POST /api/v2/domain-claims` (normalisation lowercase,
|
||||
jeton `.well-known/flowdeck-verify.txt`), `POST .../verify` (fetch HTTPS du
|
||||
domaine + comparaison), `enforce_sso` qui bloque le login local par domaine
|
||||
dans `auth.local_login` (les admins gardent l'accès local) ; jeton jamais
|
||||
renvoyé par le listing
|
||||
- **Audit unifié** — `app/routers/audit.py` : `GET /api/v2/audit/logs` fusionne
|
||||
`api_audit_log` + `permission_audit_log` + `sso_login_history` en un schéma
|
||||
commun, filtres `source`/`actor`/`action`, pagination, export
|
||||
`?format=csv` (admin ou Bearer `read:admin`)
|
||||
- **Gouvernance des agents** — `app/services/agent_policies.py` + `app/routers/governance.py` :
|
||||
`agent_policies` (liste d'outils autorisés par workspace, `max_steps`,
|
||||
`require_approval`) consultée par `AgentEngine` **avant** les ACL, file
|
||||
`agent_approvals` pour les écritures, décision admin
|
||||
(`/api/v2/agent-approvals/{id}/decide`) + événement
|
||||
`agent.run.approval_requested`
|
||||
- **Migration 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`,
|
||||
`agent_policies`, `agent_approvals` + `users.totp_secret_enc`,
|
||||
`users.totp_backup_hashes`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Les 404 sur `/scim/v2` et `/auth/webauthn` renvoyaient une redirection 302
|
||||
vers `/workspaces` (handler global) : ils retournent désormais du JSON, et
|
||||
`/scim/v2` répond en `application/scim+json`
|
||||
- `/scim/v2` et les routes 2FA/WebAuthn ajoutées à la liste d'exclusion CSRF
|
||||
(authentification Bearer, pas de cookie de session)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v72_enterprise.py` : **52 tests** (migration 28, SCIM tokens/CRUD/
|
||||
suspend/duplicate/404, 2FA setup-activate-verify-backup-chiffrement-désactivation,
|
||||
challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV +
|
||||
pagination, politiques et gate d'approbation)
|
||||
- `ruff check app tests` OK · `tests/test_agent.py` + `tests/test_app.py` :
|
||||
**261 verts** (la gouvernance ne casse pas l'engine)
|
||||
|
||||
---
|
||||
|
||||
## v7.1.0 (2026-09-28) — Calendar sync + Meeting Notes
|
||||
|
||||
> Calendrier bidirectionnel Google/CalDAV + transcription → résumé IA qui
|
||||
> déclenche les agents (pattern Notion 07/2026). Design : `docs/V71_Calendar_Meetings.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sync bidirectionnelle** — `app/services/calendar_sync.py` : `calendar_links`
|
||||
(tokens Fernet, `collection_id`, `date_property`), pull (event → ligne datée +
|
||||
`external_event_id`) + push, boucle 15 min dans le lifespan ; conflits
|
||||
(édité des 2 côtés → last-write-wins + notif `calendar.conflict`) ; lignes
|
||||
touchées par le pull jamais repoussées ; API CRUD + `POST .../sync`
|
||||
(`app/routers/meetings.py`, session ou Bearer `write`, tokens jamais leakés)
|
||||
- **Sans dépendance** — Google Calendar REST (401 → « relink »), CalDAV brut
|
||||
(REPORT + parseur multistatus, PUT) ; I/O module-level monkeypatchables
|
||||
- **Meeting Notes v2** — `app/services/meetings.py` : upload audio 100 MB
|
||||
(mp3/wav/m4a/ogg/flac/aac), transcription `STT_COMMAND` ou transcript manuel,
|
||||
résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized`
|
||||
(branché automations v7.0) ; endpoints upload/texte/summarize
|
||||
- **Free/busy** — `GET /db/{id}/calendar/freebusy` (busy + free weekdays,
|
||||
récurrences expandues, 1..370 j)
|
||||
- **Migration 27** — `calendar_links`, `meeting_transcripts`,
|
||||
`collection_pages.external_event_id` + index
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v71_calendar_meetings.py` : **15 tests** (migration, links,
|
||||
pull/push/idempotence/conflit + notif, 502, CalDAV, freebusy, meetings ×5)
|
||||
- `ruff check app tests` OK · suite **871 verts** (21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v7.0.0 (2026-09-28) — Automations v2 + Workers lite
|
||||
|
||||
> Du if-this-then-that aux chaînes multi-triggers + custom code sandboxé,
|
||||
> parité Notion Automations + Workers. Design : `docs/V70_Automations_Workers.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Automations multi-étapes** — `automation_steps` (trigger/condition/delay/action
|
||||
ordonnés) : CRUD `GET/POST /workspace/automations/{id}/steps`,
|
||||
`PUT/DELETE /workspace/automations/steps/{id}` (session, validation serveur),
|
||||
`PUT .../mode` (`any` défaut / `all` fenêtre 5 min) ; conditions AND réutilisant
|
||||
`match_condition_props` ; `form.submitted` déclenchable ; legacy sans steps intact
|
||||
(matcher legacy ignore les automatisations à steps → pas de double run)
|
||||
- **Nouvelles actions** — `slack` (incoming webhook, URL chiffrée Fernet au repos,
|
||||
décryptée à l'exécution), `email` (`user:<id>` résolu ou reply-to créateur,
|
||||
repli propre sans SMTP), `forge_issue` (Gitea via `GiteaClient` / GitHub API,
|
||||
token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`),
|
||||
`delay` (0..86400s validé, sleep plafonné 300s) ; interpolation `[[prop]]` /
|
||||
`{{title}}` conservée ; backends module-level = monkeypatchables
|
||||
- **Bouton DB natif** — type `button` (`PROPERTY_TYPES`), `button_automation_id`,
|
||||
`POST /api/automations/press-button` (CSRF-exempt, 400 explicites)
|
||||
- **Workers lite** — `app/services/workers.py` (lint AST : imports réseau/OS,
|
||||
`open/exec/eval`, dunders ; builtins restreints ; thread + timeout 30s ;
|
||||
budget journalier ; fork des partagés) + `app/routers/workers.py`
|
||||
(CRUD `/api/v2/workers*` session/Bearer `write`, code masqué aux non-owners,
|
||||
run/runs/fork/usage) ; crons branchés sur la boucle scheduler 60s
|
||||
- **Migration 26** — `automation_steps`, `workers`, `worker_runs`,
|
||||
`automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v70_automations_workers.py` : **31 tests** (migration, steps,
|
||||
any/all, chaînes, delay, 4 nouvelles actions, secret chiffré, button,
|
||||
legacy, workers ×11)
|
||||
- `ruff check app tests` OK · suite **855 verts** (21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement ; 1 flaky parallèle
|
||||
`test_env_config_fallback…` qui passe isolé)
|
||||
|
||||
---
|
||||
|
||||
## v6.9.0 (2026-09-28) — Recherche sémantique + Ask AI
|
||||
|
||||
> Retrouver (hybride lexical + vectoriel) et demander (RAG avec citations),
|
||||
> parité Notion Enterprise Search + AI Q&A. Design : `docs/V69_Search_Ask_AI.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Moteur sémantique** — `app/services/semantic_search.py` : chunking chevauchant
|
||||
(1200 cars / overlap 150, récursif dans `children`), encodeur hashed-TF `hash-256`
|
||||
(md5 % 256, L2, déterministe, zéro dépendance, `embed_texts()` pluggable),
|
||||
cosinus pur Python, fusion RRF (k=60), job incrémental `index_pending()` (batch 50,
|
||||
scheduler 5 min dans le lifespan) + `purge_orphans()`
|
||||
- **Recherche hybride** — `GET /api/v2/search/hybrid` (`app/routers/search_ai.py`,
|
||||
session ou Bearer `read`) : lexical FTS5/LIKE + vecteurs, filtre `workspace_id`,
|
||||
scope membership, `PermissionManager` (pages `restricted` masquées),
|
||||
`search_excluded` respecté, pagination + `X-Total-Count`
|
||||
- **Ask AI** — `POST /api/v2/search/ask` : top-8 chunks autorisés (ACL **avant** prompt),
|
||||
`LLMClient.complete()` si provider configuré sinon extractif offline avec
|
||||
`[[fdpage:ID]]`, citations résolues (titres, « Deleted page » gérée), cache 10 min,
|
||||
rate-limit 30/min, `api_audit_log`
|
||||
- **Observabilité** — `GET /api/v2/search/index-status` (ressources, vecteurs, modèle)
|
||||
- **Migration 25** — `semantic_embeddings`, `semantic_index_state`,
|
||||
`pages.search_excluded`
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v69_search_ask.py` : **24 tests** (migration, chunk/overlap, déterminisme,
|
||||
cosinus, index idempotent, exclusion, purge, rappel partiel, hybride ×7, ask ×6,
|
||||
index-status)
|
||||
- `ruff check app tests` OK · suite **825 verts** (seuls 21 échecs `test_v67_sso.py`
|
||||
pré-existants — `onelogin` absent de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v6.8.0 (2026-09-28) — Sites & Forms publics
|
||||
|
||||
> Parité Notion Sites + Forms : publier un mini-site multi-pages et collecter
|
||||
> des réponses anonymes dans une database. Design : `docs/V68_Sites_Forms.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sites multi-pages** — `app/routers/sites.py` : CRUD `GET/POST/PATCH/DELETE /api/v2/sites`
|
||||
(session ou Bearer + scope `write`, pagination `X-Total-Count`, `api_audit_log`),
|
||||
`GET/POST/DELETE /api/v2/sites/{id}/pages` (arbre ordonné, racine protégée),
|
||||
`GET /api/v2/sites/{id}/stats` (vues jour + total) ; rendu public `GET /s/<slug>` +
|
||||
`GET /s/<slug>/<page-slug>` (nav latérale, thèmes light/dark, blocs/synced/wiki résolus)
|
||||
- **Gating & SEO** — mot de passe (`password_utils` salé, cookie signé 24h,
|
||||
`GET|POST /s/<slug>/auth`), expiry (410), `noindex`, OG/Twitter cards,
|
||||
`GET /s/<slug>/sitemap.xml`, domaine custom via header `Host`
|
||||
- **Forms publics** — `PUT/GET /api/v2/collections/{id}/form` (`enabled`, `public_token f_*`,
|
||||
`fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>`
|
||||
(formulaire no-auth, `?embed=1` sans chrome) + `POST /f/<token>` (anonyme :
|
||||
rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ;
|
||||
chaque soumission = `collection_pages` + `form_responses` (ip_hash jour, pas d'IP),
|
||||
notif in-app + trigger `form.submitted`
|
||||
- **Migration 24** — tables `sites`, `site_pages`, `site_views`, `form_responses`,
|
||||
colonne `collections.form_config_json`
|
||||
- CSRF exempte `/s/` + `/f/` (soumissions anonymes cross-site)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v68_sites_forms.py` : **20 tests** — migration 24, CRUD, slug/conflit,
|
||||
auth 401, isolation inter-users, pages add/remove, rendu home/subpage/blocs, 404,
|
||||
vues comptées, password gate, expiry 410, sitemap, noindex, form config/submit JSON,
|
||||
required 400, 404, honeypot, rate-limit 429, embed
|
||||
- `ruff check app tests` OK · pas de régression (801 verts ; seuls 21 échecs
|
||||
`test_v67_sso.py` pré-existants — dépendance `onelogin` absente de l'environnement)
|
||||
|
||||
---
|
||||
|
||||
## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
|
||||
|
||||
> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
|
||||
|
||||
+205
-16
@@ -942,7 +942,7 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
|
||||
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
@@ -954,6 +954,105 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)
|
||||
|
||||
> **Contexte (2026-09-28)** : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité
|
||||
> (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO).
|
||||
> Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : **Sites, Forms,
|
||||
> Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP**.
|
||||
> Référence : `notion.com/product/features` + `notion.com/releases` (Workers, Meeting Notes → agents, Developer bar, MCP).
|
||||
|
||||
### v6.8.0 — Sites & Forms publics ✅ (2026-09-28)
|
||||
> **Objectif** : publier (site multi-pages) et collecter (formulaires anonymes).
|
||||
> Parité Notion Sites + Forms. **Doc** : [`docs/V68_Sites_Forms.md`](docs/V68_Sites_Forms.md)
|
||||
|
||||
- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
|
||||
- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s/<slug>/sitemap.xml`, meta robots, preview sociale
|
||||
- [x] **Gating public** — mot de passe (hash salé `password_utils`, cookie signé 24h), expiry (410), `noindex`, analytics vues (`site_views` : jour + compteur, pas d'IP brute)
|
||||
- [x] **Routes** — `GET /s/<slug>` (home), `GET /s/<slug>/<page-slug>` (résolution slug + id), `GET|POST /s/<slug>/auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
|
||||
- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>` (no-auth) + `POST /f/<token>` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
|
||||
- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
|
||||
- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
|
||||
- [x] **Tests** — `tests/test_v68_sites_forms.py` (**20 tests** : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
|
||||
- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
|
||||
|
||||
### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
|
||||
> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
|
||||
> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
|
||||
|
||||
- [x] **Embeddings** — tables `semantic_embeddings` (`resource_type`, `resource_id`, `chunk_id`, `chunk_text`, `embedding BLOB`, `model hash-256`) + `search_index_state` ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, `embed_texts()` pluggable) ; job incrémental (`updated_at > indexed_at`, batch 50, scheduler 5 min dans le lifespan) + `purge_orphans()`
|
||||
- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
|
||||
- [x] **Ask AI** — `POST /api/v2/search/ask` (`{question, workspace_id}` → `{answer_markdown, citations}`) : top-8 chunks autorisés (ACL avant prompt), `LLMClient.complete()` si provider configuré sinon extractif offline avec `[[fdpage:ID]]`, cache 10 min, rate-limit 30/min, audit
|
||||
- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
|
||||
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
|
||||
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
|
||||
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
|
||||
|
||||
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
|
||||
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
|
||||
> Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe).
|
||||
> **Doc** : [`docs/V70_Automations_Workers.md`](docs/V70_Automations_Workers.md)
|
||||
|
||||
- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
|
||||
- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
|
||||
- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
|
||||
- [x] **Compat legacy** — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
|
||||
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
||||
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
|
||||
|
||||
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
|
||||
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
||||
> **Doc** : [`docs/V71_Calendar_Meetings.md`](docs/V71_Calendar_Meetings.md)
|
||||
|
||||
- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
|
||||
- [x] **Providers sans dépendance** — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
|
||||
- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
|
||||
- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
|
||||
- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
|
||||
- [x] **Tests** — `tests/test_v71_calendar_meetings.py` (**15 tests** : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + `meeting.summarized` → automation, validations audio, 404)
|
||||
- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
|
||||
### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
|
||||
> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
|
||||
> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
|
||||
|
||||
- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
|
||||
- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
|
||||
- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https://<domain>/.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
|
||||
- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
|
||||
- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
|
||||
- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
|
||||
- [x] **Tests** — `tests/test_v72_enterprise.py` (**52 tests** : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
|
||||
- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
|
||||
|
||||
### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
|
||||
> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
|
||||
|
||||
- [x] **Teamspaces** — `teamspaces` (`workspace_id`, `name`, `private`) + `teamspace_members` (rôles owner/editor/commenter/viewer) ; `pages.teamspace_id` + `collections.teamspace_id` ; `private` → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
|
||||
- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
|
||||
- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `<pre class="mermaid">` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
|
||||
- [x] **Collab polish** — `comment_reactions` (agrégation emoji + toggle), `page_follows` (toggle + followers), `guest_shares` → `GET /g/{token>` sans compte (vue enregistrée, 404 HTML si expiré/révoqué), `page_views` (compteurs journaliers, séries sans trou)
|
||||
- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
|
||||
- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
|
||||
- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
|
||||
- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
|
||||
- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
|
||||
- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
|
||||
- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
|
||||
- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
|
||||
- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
|
||||
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
|
||||
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
| Feature | Fichiers | Note |
|
||||
@@ -963,21 +1062,33 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
|
||||
|
||||
### Cycle v6 — livré, pour mémoire
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ livré
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ livré
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ livré
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ livré
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ livré
|
||||
13. ~~**v5.14.0 → Synced blocks**~~ ✅ livré · ~~**v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO**~~ ✅ livré
|
||||
|
||||
### Cycle v7 — à livrer
|
||||
| Ordre | Version | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | **v6.8.0 → Sites & Forms publics** | M | 🔴 publier + collecter |
|
||||
| 2 | **v6.9.0 → Search sémantique + Ask AI** | M | 🔴 retrouver |
|
||||
| 3 | **v7.0.0 → Automations v2 + Workers** | L | 🔴 automatiser |
|
||||
| 4 | **v7.1.0 → Calendar sync + Meeting Notes** | M | 🟠 |
|
||||
| 5 | **v7.2.0 → SCIM + 2FA + Audit UI** | M | 🟠 enterprise |
|
||||
| 6 | **v7.3.0 → Wiki/Teamspaces + Polish** | S–M | 🟢 finition |
|
||||
|
||||
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
|
||||
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -996,4 +1107,82 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.*
|
||||
*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Anomalies & dette technique — audit complet 2026-09-30
|
||||
|
||||
> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
|
||||
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
|
||||
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
|
||||
|
||||
### 🔴 P0 — Critique (avant toute exposition réseau)
|
||||
|
||||
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
|
||||
|
||||
### 🟠 P1 — Hautes
|
||||
|
||||
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
|
||||
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
|
||||
- [ ] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
|
||||
- [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
|
||||
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
|
||||
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
||||
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
|
||||
- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
|
||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
||||
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
|
||||
- [ ] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
|
||||
|
||||
### 🟡 P2 — Moyennes
|
||||
|
||||
- [ ] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
|
||||
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.*
|
||||
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
|
||||
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
|
||||
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
|
||||
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
|
||||
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
|
||||
- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
|
||||
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
|
||||
- [ ] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**.*
|
||||
- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
|
||||
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
|
||||
|
||||
### 🟢 P3 — Basses / hygiène
|
||||
|
||||
- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.*
|
||||
- [ ] **A39 — HTMX chargé (50 Ko) pour 10 attributs** vs 265 `fetch(` manuels (36 `hx-*` dont 10 réels ; `settings.html` 42 fetch/0 hx, `_page_editor_scripts` 56/0). *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien. Effort : **XS**.*
|
||||
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
|
||||
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
|
||||
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
|
||||
- [ ] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
|
||||
|
||||
### ✅ Vérifié non-problème (ne pas re-checker)
|
||||
|
||||
`sort`/`direction` de l'`ORDER BY` f-string **whitelisté** (`api_v2.py:696-703`) · `permissions._grant_common(table, …)` ne reçoit que des littéraux de ses 3 appelants · `admin.py` utilise bien `Depends(admin_required)` · `/api/v2/*` = `get_bearer_user` + scopes · SCIM Bearer-only (commenté `csrf.py:19-20`) · `serve_uploaded_file` et `_file_page_disk_path` ont la bonne garde de traversal · `url_fetch._is_public_host` est un vrai garde SSRF (à réutiliser) · aucun `password_hash`/valeur de token dans les réponses API (les tests le couvrent) · `.env` bien gitignoré, aucun secret en log · les 10 schedulers ont un `try` interne · 0 test sans `assert`, 0 test sur le réseau réel.
|
||||
|
||||
### ⚡ Correctifs immédiats (avant le prochain cycle — ~30 min au total)
|
||||
|
||||
1. **A2** : commit + push + tag du cycle v7 (sinon tout le reste risque de partir avec une réinit).
|
||||
2. **A1** : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` → les 13 échecs deps disparaissent, le 14ᵉ reste à isoler.
|
||||
3. **A9** : `git rm --cached flowdeck.db flowdeck_dev.db test-commit.md upload_test.txt` + `.gitignore`/`.dockerignore` + rotation de `app_secret_key`.
|
||||
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6.**
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0)
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.6 (audit sécurité — A19 terminé) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -28,6 +28,15 @@
|
||||
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
|
||||
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
|
||||
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
|
||||
| v6.8.0 | Sites multi-pages + Forms publics ([doc](docs/V68_Sites_Forms.md)) | ✅ | 20 |
|
||||
| v6.9.0 | Recherche hybride + Ask AI RAG ([doc](docs/V69_Search_Ask_AI.md)) | ✅ | 24 |
|
||||
| v7.0.0 | Automations multi-étapes + Workers lite ([doc](docs/V70_Automations_Workers.md)) | ✅ | 31 |
|
||||
| v7.1.0 | Calendar sync + Meeting Notes ([doc](docs/V71_Calendar_Meetings.md)) | ✅ | 15 |
|
||||
| v7.2.0 | SCIM + 2FA + Audit UI + gouvernance agents ([doc](docs/V72_Enterprise_SCIM_2FA.md)) | ✅ | 52 |
|
||||
| v7.3.0 | Teamspaces + Verified + Polish + follow-ups ([doc](docs/V73_Wiki_Teamspaces_Polish.md)) | ✅ | 72 |
|
||||
|
||||
> **Suites de régression** : `test_v72_enterprise.py` (52) + `test_v73_wiki_polish.py` (72) = **124 verts** · suite complète `-n auto` = **1016 passed**.
|
||||
> **Follow-ups v7.3 livrés** (voir § v7.3.0 du `ROADMAP.md`): sidebar par teamspace, charts `number`/multi-DB, notif `page.updated` aux followers, unfurl `gitea:`/`github:`, page Settings → Audit — + 21 casses SSO corrigées (install `python3-saml`/`authlib`).
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
|
||||
+65
-11
@@ -40,19 +40,28 @@ from app.routers import (
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.api_v2_agent import router as api_v2_agent_router
|
||||
from app.routers.audit import router as audit_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.routers.governance import router as governance_router
|
||||
from app.routers.imports import page_router as import_page_router
|
||||
from app.routers.imports import router as imports_router
|
||||
from app.routers.meetings import router as meetings_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.scim import router as scim_router
|
||||
from app.routers.search_ai import router as search_ai_router
|
||||
from app.routers.sites import router as sites_router
|
||||
from app.routers.sso import router as sso_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.routers.webauthn import router as webauthn_router
|
||||
from app.routers.wiki import router as wiki_router
|
||||
from app.routers.workers import router as workers_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -66,15 +75,27 @@ logger = logging.getLogger(__name__)
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
|
||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone():
|
||||
admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password(admin_pw),),
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
"Premier démarrage : compte admin créé, mot de passe = %s "
|
||||
"(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)",
|
||||
admin_pw,
|
||||
)
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
@@ -100,6 +121,14 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = asyncio.create_task(semantic_index_scheduler())
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = asyncio.create_task(calendar_sync_scheduler())
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
@@ -110,7 +139,7 @@ async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
@@ -124,7 +153,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.7.0",
|
||||
version="7.3.6",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -173,6 +202,17 @@ app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
app.include_router(api_v2_agent_router)
|
||||
app.include_router(sites_router)
|
||||
app.include_router(search_ai_router)
|
||||
app.include_router(workers_router)
|
||||
app.include_router(meetings_router)
|
||||
# v7.2.0 — enterprise admin
|
||||
app.include_router(scim_router)
|
||||
app.include_router(webauthn_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(governance_router)
|
||||
# v7.3.0 — teamspaces + verified wiki
|
||||
app.include_router(wiki_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -262,17 +302,31 @@ async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
is_api_v2 = request.url.path.startswith("/api/v2")
|
||||
# Programmatic API prefixes that must always answer JSON errors instead of
|
||||
# being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch).
|
||||
JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn")
|
||||
is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES)
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
if is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
if is_json_api and request.url.path.startswith("/scim/v2"):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": detail if isinstance(detail, str) else "Not found",
|
||||
"status": "404"},
|
||||
status_code=404,
|
||||
headers={"Content-Type": "application/scim+json"},
|
||||
)
|
||||
if "/api" in request.url.path or is_json_api:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
if is_api_v2:
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
@@ -985,6 +985,459 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(24, "v6.8.0: Sites & public Forms")
|
||||
def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
||||
"""v6.8.0 — Notion Sites + Forms publics (voir docs/V68_Sites_Forms.md).
|
||||
|
||||
``sites`` — mini-site multi-pages (slug, root_page, thème,
|
||||
domaine custom, password hash, expiry, noindex).
|
||||
``site_pages`` — arbre public ordonné (site_id, page_id, position).
|
||||
``site_views`` — compteur de vues jour/site (upsert, pas d'IP brute).
|
||||
``form_responses`` — log des soumissions anonymes (ip_hash jour, pas d'IP).
|
||||
``collections.form_config_json`` — config du formulaire public par DB.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
theme TEXT NOT NULL DEFAULT 'dark',
|
||||
custom_domain TEXT UNIQUE,
|
||||
password_hash TEXT DEFAULT '',
|
||||
expires_at TIMESTAMP,
|
||||
noindex INTEGER NOT NULL DEFAULT 0,
|
||||
analytics_id TEXT DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS site_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(site_id, page_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_site_pages_site ON site_pages(site_id, position)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS site_views (
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (site_id, day)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS form_responses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
row_id INTEGER REFERENCES collection_pages(id) ON DELETE SET NULL,
|
||||
ip_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "form_config_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
||||
)
|
||||
|
||||
|
||||
@register(25, "v6.9.0: semantic search + Ask AI")
|
||||
def _migration_semantic_search(conn: sqlite3.Connection) -> None:
|
||||
"""v6.9.0 — hybrid lexical+vector search and RAG Ask AI (docs/V69_* md).
|
||||
|
||||
``semantic_embeddings`` — hashed-TF chunk vectors (no external dep):
|
||||
keyed by (resource_type, resource_id, chunk_id) so both ``page``
|
||||
and ``collection`` resources are indexed. (Design doc names a
|
||||
``page_embeddings`` table; the generic key covers collections too.)
|
||||
``semantic_index_state`` — last indexed timestamp per resource for the
|
||||
incremental background job.
|
||||
``pages.search_excluded`` — opt-out flag respected by indexer + search.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS semantic_embeddings (
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id INTEGER NOT NULL,
|
||||
chunk_id INTEGER NOT NULL,
|
||||
chunk_text TEXT NOT NULL DEFAULT '',
|
||||
embedding BLOB NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT 'hash-256',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (resource_type, resource_id, chunk_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sem_emb_res "
|
||||
"ON semantic_embeddings(resource_type, resource_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS semantic_index_state (
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id INTEGER NOT NULL,
|
||||
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (resource_type, resource_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "search_excluded" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
|
||||
@register(26, "v7.0.0: automations v2 (steps) + workers")
|
||||
def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
|
||||
"""v7.0.0 — multi-step automations + sandboxed workers (docs/V70_* md).
|
||||
|
||||
``automation_steps`` — ordered trigger/condition/delay/action chain per
|
||||
automation. Legacy single trigger+actions columns keep working
|
||||
(engine falls back when an automation has no steps).
|
||||
``automations.trigger_mode`` — ``any`` (default) or ``all`` (every
|
||||
trigger event must arrive within a 5-minute window).
|
||||
``workers`` / ``worker_runs`` — custom Python snippets (cron/manual),
|
||||
shareable across the team, with execution logs + daily budget.
|
||||
``collection_properties.button_automation_id`` — native DB button cells.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS automation_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asteps_auto "
|
||||
"ON automation_steps(automation_id, position)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
code_py TEXT NOT NULL DEFAULT '',
|
||||
schedule_cron TEXT DEFAULT '',
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
daily_budget_s INTEGER NOT NULL DEFAULT 60,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS worker_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
logs TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||
"ON worker_runs(worker_id, created_at)"
|
||||
)
|
||||
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "button_automation_id" not in prop_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
||||
"INTEGER REFERENCES automations(id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(27, "v7.1.0: calendar sync + meeting transcripts")
|
||||
def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
|
||||
"""v7.1.0 — external calendar sync + AI meeting notes (docs/V71_* md).
|
||||
|
||||
``calendar_links`` — per-user link between a collection and an external
|
||||
calendar (google REST / generic caldav), tokens Fernet-encrypted.
|
||||
``meeting_transcripts`` — uploaded audio + transcript + AI summary per page.
|
||||
``collection_pages.external_event_id`` — remote event id for push/pull
|
||||
matching and conflict detection.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS calendar_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
tokens_enc TEXT NOT NULL DEFAULT '',
|
||||
calendar_id TEXT NOT NULL DEFAULT 'primary',
|
||||
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
|
||||
date_property TEXT DEFAULT '',
|
||||
sync_token TEXT DEFAULT '',
|
||||
last_sync TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider, calendar_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_transcripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
audio_path TEXT NOT NULL DEFAULT '',
|
||||
transcript TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT 'fr',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||
"ON meeting_transcripts(page_id)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
if "external_event_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cp_external "
|
||||
"ON collection_pages(collection_id, external_event_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(28, "v7.2.0: SCIM + 2FA + audit UI + agent governance")
|
||||
def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
|
||||
"""v7.2.0 — enterprise admin (docs/V72_* md).
|
||||
|
||||
``scim_tokens`` — Bearer tokens for SCIM provisioning (admin-managed).
|
||||
``domain_claims`` — DNS/well-known verified domains + SSO enforcement.
|
||||
``webauthn_credentials`` — passkeys (credential_id, COSE public key).
|
||||
``agent_policies`` — per-workspace tool scope + approval gate.
|
||||
``agent_approvals`` — approval queue for gated write actions.
|
||||
``users.totp_secret_enc`` / ``totp_backup_hashes`` — TOTP 2FA.
|
||||
(``users.is_active`` already exists — used by SCIM suspend.)
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scim_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS domain_claims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
txt_token TEXT NOT NULL DEFAULT '',
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
auto_join_role TEXT NOT NULL DEFAULT 'viewer',
|
||||
enforce_sso INTEGER NOT NULL DEFAULT 0,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
allowed_tools_json TEXT,
|
||||
max_steps INTEGER NOT NULL DEFAULT 12,
|
||||
require_approval INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL DEFAULT 0,
|
||||
tool TEXT NOT NULL DEFAULT '',
|
||||
args_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requester_id INTEGER REFERENCES users(id),
|
||||
approver_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||
"ON agent_approvals(status, created_at)"
|
||||
)
|
||||
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "totp_secret_enc" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||
if "totp_backup_hashes" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_backup_hashes TEXT DEFAULT '[]'")
|
||||
|
||||
|
||||
@register(29, "v7.3.0: teamspaces + verified pages + collab polish")
|
||||
def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
|
||||
"""v7.3.0 — teamspaces, verified pages, collab polish (docs/V73_*.md).
|
||||
|
||||
``teamspaces`` / ``teamspace_members`` — namespaces for pages + databases;
|
||||
``private=1`` hides a teamspace from non-members (404, like restricted
|
||||
collections). ``page_verifications`` — ✅ badge with expiry.
|
||||
``comment_reactions`` / ``page_follows`` — collab polish.
|
||||
``guest_shares`` — account-less page access via ``/g/<token>``.
|
||||
``page_views`` — daily counters, same pattern as ``site_views``.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS teamspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
private INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS teamspace_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(teamspace_id, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_teamspace_members_user "
|
||||
"ON teamspace_members(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_verifications (
|
||||
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
|
||||
verified_by INTEGER REFERENCES users(id),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_verifications_expiry "
|
||||
"ON page_verifications(expires_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS comment_reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(comment_id, user_id, emoji)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_follows (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (page_id, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS guest_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
expires_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (page_id, day)
|
||||
)
|
||||
"""
|
||||
)
|
||||
for table in ("pages", "collections"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "teamspace_id" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||
|
||||
|
||||
@register(23, "v6.7.0: SSO/SAML enterprise auth")
|
||||
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
|
||||
|
||||
+24
-20
@@ -5,7 +5,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
|
||||
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
|
||||
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
|
||||
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
|
||||
|
||||
|
||||
async def _require_session_or_bearer(request: Request) -> None:
|
||||
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
|
||||
if request.url.path in _API_PUBLIC_PATHS:
|
||||
return
|
||||
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
from app.routers.public_api import verify_token
|
||||
verify_token(auth)
|
||||
return
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
|
||||
|
||||
# ── Simple rate limiter (in-memory, per-IP) ──
|
||||
_rate_limit_store: dict[str, tuple[float, int]] = {}
|
||||
@@ -79,22 +99,6 @@ async def stats():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
|
||||
"""List Gitea projects (JSON)."""
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception:
|
||||
repos = []
|
||||
return {"projects": repos}
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_card(
|
||||
request: Request,
|
||||
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -318,12 +318,32 @@ async def local_login(request: Request):
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
|
||||
if not ud.get("is_admin"):
|
||||
with get_conn() as conn:
|
||||
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
|
||||
if dom:
|
||||
enforced = conn.execute(
|
||||
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
|
||||
" AND enforce_sso=1", (dom,)).fetchone()
|
||||
if enforced:
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled for your domain — sign in with SSO"},
|
||||
status_code=403)
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
|
||||
from app.services import two_factor as _2fa
|
||||
if _2fa.is_enabled(ud["id"]):
|
||||
return JSONResponse({"status": "2fa_required",
|
||||
"pending": _2fa.mint_pending(ud["id"])})
|
||||
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
@@ -458,6 +478,87 @@ async def current_user(request: Request):
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
if not _2fa.verify_code(user_id, body.get("code", "")):
|
||||
return JSONResponse({"error": "Invalid code"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
ud = dict(row)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _session_user_or_401(request: Request) -> dict:
|
||||
from fastapi import HTTPException
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid code — secret not activated"},
|
||||
status_code=400)
|
||||
return {"status": "enabled", "backup_codes": codes}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
return {"status": "disabled"}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
|
||||
+153
-4
@@ -4,14 +4,28 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import get_page_context, run_automation
|
||||
from app.services.automations import (
|
||||
get_page_context,
|
||||
get_steps,
|
||||
press_button,
|
||||
run_automation,
|
||||
validate_step,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
|
||||
|
||||
def _require_session(request: Request) -> None:
|
||||
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
@@ -69,7 +83,7 @@ async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
by = user["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
@@ -172,3 +186,138 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
|
||||
|
||||
STEP_SECRET_FIELDS = {"webhook_url"}
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _get_auto(auto_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _auto_404():
|
||||
# NOTE: return (not raise) — the global 404 handler redirects non-/api
|
||||
# paths to /workspaces, which TestClient follows into a 200.
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Automation not found"}, status_code=404)
|
||||
|
||||
|
||||
def _encrypt_step_config(config: dict) -> dict:
|
||||
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
cfg = dict(config or {})
|
||||
for field in STEP_SECRET_FIELDS:
|
||||
if field in cfg and cfg[field]:
|
||||
val = str(cfg[field])
|
||||
if not val.startswith("gAAAAA"):
|
||||
cfg[field] = encrypt_secret(val)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
with get_conn() as conn:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
|
||||
(auto_id,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
|
||||
" VALUES (?,?,?,?)",
|
||||
(auto_id, kind, int(body.get("position", pos)),
|
||||
json.dumps(_encrypt_step_config(config))))
|
||||
conn.commit()
|
||||
step_id = cur.lastrowid
|
||||
return {"id": step_id, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Step not found"}, status_code=404)
|
||||
kind = body.get("kind", row["kind"])
|
||||
try:
|
||||
config = body.get("config", json.loads(row["config_json"] or "{}"))
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
config = {}
|
||||
validate_step(kind, config if isinstance(config, dict) else {})
|
||||
conn.execute(
|
||||
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
|
||||
(kind, int(body.get("position", row["position"])),
|
||||
json.dumps(_encrypt_step_config(config)), step_id))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "trigger_mode": mode}
|
||||
|
||||
|
||||
@router.post("/api/automations/press-button")
|
||||
async def press_button_endpoint(request: Request):
|
||||
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
row_id = int(body.get("row_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
|
||||
prop_ref = body.get("property", body.get("property_id", ""))
|
||||
if not prop_ref:
|
||||
raise HTTPException(status_code=400, detail="property required")
|
||||
user = _current_user(request)
|
||||
try:
|
||||
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return result
|
||||
|
||||
+89
-25
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@@ -803,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -907,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -1083,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
@@ -1212,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
@@ -1239,8 +1240,8 @@ async def board_view(
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
@@ -1384,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
@@ -1416,11 +1420,11 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1456,7 +1460,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown"})
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -1522,7 +1527,8 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1883,7 +1889,11 @@ async def import_file(request: Request):
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card."""
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1891,11 +1901,62 @@ async def og_metadata(request: Request):
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
data = await fetch_og_metadata(url)
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
@@ -1990,8 +2051,8 @@ async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
@@ -2084,12 +2145,15 @@ async def sync_project(owner: str, repo: str):
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
(board_id, issue["number"], col),
|
||||
)
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
|
||||
@@ -107,6 +107,12 @@ async def add_comment(request: Request, page_id: int):
|
||||
comment_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
# v7.3.0: commenting implies following — the author gets the
|
||||
# (throttled) page.updated notifications like any other follower.
|
||||
from app.services import wiki as wiki_svc
|
||||
wiki_svc.ensure_follow(page_id, uid, conn=conn)
|
||||
conn.commit()
|
||||
|
||||
# Notify users @-mentioned in the comment (skip the author).
|
||||
url = _page_url(page_id)
|
||||
title = f"New comment on “{page['title']}”"
|
||||
|
||||
+185
-26
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
@@ -42,23 +43,22 @@ def _session_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
@@ -373,20 +373,35 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in rows:
|
||||
ncur = conn.execute(
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"]),
|
||||
tuples,
|
||||
)
|
||||
prop_map[p["id"]] = ncur.lastrowid
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
@@ -1898,6 +1913,71 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
@@ -1912,9 +1992,14 @@ async def view_collection(request: Request, collection_id: int, view_type: str =
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
@@ -2188,27 +2273,101 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN."""
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages:
|
||||
labels.append(p["title"][:30])
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
val = 0
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0
|
||||
values.append(val if val else 1)
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
@@ -2223,7 +2382,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{collection["name"]}',
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
@@ -2231,7 +2390,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{len(pages)} entries</p>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
+113
-53
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -252,10 +252,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -283,10 +282,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -312,10 +310,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -437,8 +434,8 @@ async def view_page_root(request: Request, page_id: int):
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -451,8 +448,8 @@ async def accounts_page(request: Request):
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
@@ -656,8 +653,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -689,11 +686,20 @@ def _get_user_id(request: Request) -> int:
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
@@ -702,13 +708,18 @@ async def update_profile(request: Request):
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
from app.password_utils import hash_password
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -717,7 +728,7 @@ async def update_password(request: Request):
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -730,7 +741,7 @@ async def generate_token(request: Request):
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
@@ -755,14 +766,14 @@ async def dashboard(
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
@@ -807,8 +818,8 @@ async def dashboard(
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
@@ -820,8 +831,8 @@ async def dashboard(
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -839,8 +850,8 @@ async def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -901,9 +912,18 @@ async def list_workspace_projects(request: Request):
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
@@ -1016,8 +1036,8 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1138,9 +1158,21 @@ def _file_page_disk_path(page: dict):
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
async def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1161,13 +1193,15 @@ async def download_page_file(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
async def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1515,7 +1549,8 @@ async def upload_local_workspace_file(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1532,7 +1567,12 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1553,10 +1593,14 @@ async def upload_local_workspace_file(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
@@ -1588,7 +1632,8 @@ async def upload_local_workspace_folder(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1610,7 +1655,12 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1669,9 +1719,13 @@ async def upload_local_workspace_folder(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
@@ -1721,8 +1775,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1824,8 +1878,8 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1873,7 +1927,14 @@ async def serve_avatar_file(filename: str):
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
filepath = Path("/data/avatars") / filename
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
@@ -2142,9 +2203,8 @@ async def sidebar_workspace_tree(request: Request):
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2180,7 +2240,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
@@ -2201,7 +2261,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2221,7 +2281,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
|
||||
+15
-5
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
def _load_page_or_404(page_id: int) -> dict:
|
||||
def _load_page_or_404(request: Request, page_id: int) -> dict:
|
||||
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
|
||||
doit pas délivrer le contenu d'une page énumérable par id."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -52,7 +62,7 @@ def _safe_filename(page: dict, ext: str) -> str:
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
headers = _download_header(filename, "text/markdown")
|
||||
@@ -61,7 +71,7 @@ async def export_markdown(page_id: int, request: Request):
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
headers = _download_header(filename, "text/html")
|
||||
@@ -70,7 +80,7 @@ async def export_html(page_id: int, request: Request):
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
@@ -85,7 +95,7 @@ async def export_pdf(page_id: int, request: Request):
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
page = _load_page_or_404(request, page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
filename = f"{title}_site.zip"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import agent_policies as policies
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["governance"])
|
||||
|
||||
|
||||
def _owner_or_admin(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
is_admin = bool(row and row["is_admin"])
|
||||
if not is_admin and request.query_params.get("workspace_id"):
|
||||
member = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(request.query_params.get("workspace_id"), user["id"])).fetchone()
|
||||
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(request.query_params.get("workspace_id"),
|
||||
user["id"])).fetchone()
|
||||
if not member and not owner:
|
||||
raise HTTPException(403, "Workspace access required")
|
||||
if member and member["role"] not in ("admin", "editor", "owner"):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
user["is_admin"] = is_admin
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-policies")
|
||||
async def list_policies(request: Request):
|
||||
_owner_or_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||
return {"policies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
raise HTTPException(400, "allowed_tools must be a list or null")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
|
||||
require_approval)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
allowed_tools_json=excluded.allowed_tools_json,
|
||||
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
|
||||
(wid, json.dumps(tools) if tools is not None else None,
|
||||
max(1, min(int(body.get("max_steps") or 12), 50)),
|
||||
1 if body.get("require_approval") else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
|
||||
(wid,)).fetchone()
|
||||
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
|
||||
return JSONResponse(status_code=201, content=dict(row))
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-approvals")
|
||||
async def list_approvals(request: Request):
|
||||
_owner_or_admin(request)
|
||||
status = request.query_params.get("status", "pending")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
|
||||
" LIMIT 100", (status,)).fetchall()
|
||||
return {"approvals": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
|
||||
out["status"], request)
|
||||
return out
|
||||
@@ -44,9 +44,9 @@ async def import_page(request: Request):
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""FlowDeck — Calendar links + Meetings API (v7.1.0).
|
||||
|
||||
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
|
||||
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
|
||||
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
|
||||
transcript, AI summary (fires ``meeting.summarized``).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import calendar_sync as cal
|
||||
from app.services import meetings as meet
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["calendar-meetings"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "collection_id required") from None
|
||||
creds = body.get("credentials") or {}
|
||||
if provider == "google" and not creds.get("access_token"):
|
||||
raise HTTPException(400, "google needs credentials.access_token")
|
||||
if provider == "caldav" and not creds.get("url"):
|
||||
raise HTTPException(400, "caldav needs credentials.url")
|
||||
try:
|
||||
out = cal.save_link(user["id"], provider, collection_id, creds,
|
||||
body.get("calendar_id") or "primary",
|
||||
body.get("date_property") or "")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/calendar-links")
|
||||
async def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async def remove_link(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
if not cal.delete_link(user["id"], link_id):
|
||||
raise HTTPException(404, "Link not found")
|
||||
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
|
||||
return {"status": "deleted", "id": link_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/calendar-links/{link_id}/sync")
|
||||
async def sync_now(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Link not found")
|
||||
try:
|
||||
stats = await cal.sync_link(link_id)
|
||||
except (cal.SyncError, ValueError) as exc:
|
||||
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
|
||||
str(exc)) from None
|
||||
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
|
||||
return {"link_id": link_id, **stats}
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
||||
async def freebusy(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
|
||||
qp.get("date_property", ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return out
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
async def upload_and_transcribe(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
raise HTTPException(400, "multipart upload required") from None
|
||||
upload = form.get("audio")
|
||||
try:
|
||||
page_id = int(form.get("page_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "page_id required") from None
|
||||
language = (form.get("language") or "fr")[:10]
|
||||
manual = (form.get("transcript") or "").strip()
|
||||
if upload is None and not manual:
|
||||
raise HTTPException(400, "audio file or transcript required")
|
||||
audio_path = ""
|
||||
if upload is not None:
|
||||
filename = (upload.filename or "").lower()
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext not in meet.AUDIO_EXTENSIONS:
|
||||
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
|
||||
data = await upload.read()
|
||||
if len(data) > meet.MAX_AUDIO_BYTES:
|
||||
raise HTTPException(413, "audio exceeds 100 MB")
|
||||
if not data:
|
||||
raise HTTPException(400, "empty audio file")
|
||||
audio_path = str(meet.meetings_dir()
|
||||
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
|
||||
with open(audio_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
transcript = manual
|
||||
if not transcript and audio_path:
|
||||
try:
|
||||
transcript = meet.transcribe_audio(audio_path, language)
|
||||
except meet.TranscriptionUnavailable as exc:
|
||||
transcript = "" # stored; client transcribes or posts manual text later
|
||||
_ = exc
|
||||
try:
|
||||
tid = meet.save_transcript(page_id, transcript, language, audio_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
**row_to_dict(row), "transcribed": bool(transcript)})
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
|
||||
(text, transcript_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
|
||||
async def summarize(transcript_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = await meet.summarize_transcript(transcript_id, user.get("id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
</div>"""
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
|
||||
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
|
||||
).fetchone()
|
||||
content = row["content"] if row else ""
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
|
||||
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
|
||||
@@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token.
|
||||
|
||||
When an authenticated session is present the token is bound to that user
|
||||
(revocable from Settings → API tokens); otherwise a legacy shared token is
|
||||
created for backward compatibility.
|
||||
"""
|
||||
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
if user and user.get("id"):
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0).
|
||||
|
||||
``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and
|
||||
deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and
|
||||
revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification +
|
||||
optional local-login enforcement per email domain.
|
||||
|
||||
See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
|
||||
# ── auth ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _scim_guard(request: Request) -> dict:
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0",
|
||||
(digest,)).fetchone()
|
||||
if row:
|
||||
return {"scim_token_id": row["id"], "name": row["name"]}
|
||||
raise HTTPException(401, "SCIM token required")
|
||||
|
||||
|
||||
def _admin_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _scim_user(row) -> dict:
|
||||
d = dict(row)
|
||||
return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"],
|
||||
"name": {"formatted": d.get("full_name") or d["login"]},
|
||||
"emails": [{"value": d.get("email") or "", "primary": True}],
|
||||
"active": bool(d.get("is_active", 1)),
|
||||
"meta": {"resourceType": "User"}}
|
||||
|
||||
|
||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scim/v2/Users")
|
||||
async def scim_list(request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||
items = [_scim_user(r) for r in rows]
|
||||
return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": len(items), "Resources": items}
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
email = ""
|
||||
for em in body.get("emails") or []:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
email = em["value"]
|
||||
break
|
||||
name = ((body.get("name") or {}).get("formatted") or username)[:200]
|
||||
active = body.get("active", True)
|
||||
with get_conn() as conn:
|
||||
if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone():
|
||||
raise HTTPException(409, "User already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_active, auth_method)"
|
||||
" VALUES (?,?,?,?,'saml')",
|
||||
(username, name, email, 1 if active else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
return JSONResponse(status_code=201, content=_scim_user(row))
|
||||
|
||||
|
||||
@router.get("/scim/v2/Users/{user_id}")
|
||||
async def scim_get(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
updates: dict = {}
|
||||
if "userName" in body and body["userName"]:
|
||||
updates["login"] = body["userName"].strip()
|
||||
if isinstance(body.get("name"), dict) and body["name"].get("formatted"):
|
||||
updates["full_name"] = body["name"]["formatted"][:200]
|
||||
if isinstance(body.get("emails"), list):
|
||||
for em in body["emails"]:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
updates["email"] = em["value"][:200]
|
||||
break
|
||||
if "active" in body:
|
||||
updates["is_active"] = 1 if body["active"] else 0
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id))
|
||||
if body.get("active") is False:
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
if path in ("username", "active"):
|
||||
flat["userName" if path == "username" else "active"] = op.get("value")
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, {**body, **flat})
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.delete("/scim/v2/Users/{user_id}")
|
||||
async def scim_delete(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
# Deprovision = suspend (keeps content + audit trail).
|
||||
conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,))
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)"
|
||||
" VALUES (?,?,?)",
|
||||
(digest, str(body.get("name") or "SCIM")[:120], admin["id"]))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request)
|
||||
return JSONResponse(status_code=201,
|
||||
content={"id": cur.lastrowid, "token": raw,
|
||||
"warning": "shown once"})
|
||||
|
||||
|
||||
@router.get("/api/v2/scim/tokens")
|
||||
async def list_scim_tokens(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||
" FROM scim_tokens ORDER BY id DESC").fetchall()
|
||||
return {"tokens": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
||||
async def revoke_scim_token(token_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request)
|
||||
return {"status": "revoked", "id": token_id}
|
||||
|
||||
|
||||
# ── domain claims ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/domain-claims")
|
||||
async def list_domains(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d.pop("txt_token", None)
|
||||
out.append(d)
|
||||
return {"domains": out}
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
token = f"flowdeck-verify={secrets.token_hex(16)}"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO domain_claims
|
||||
(domain, txt_token, auto_join_role, enforce_sso, workspace_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(domain, token, body.get("auto_join_role") or "viewer",
|
||||
1 if body.get("enforce_sso") else 0, body.get("workspace_id")))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Domain already claimed") from None
|
||||
did = cur.lastrowid
|
||||
audit_log(admin, "domain.claim", "domain", did, domain, request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": did, "domain": domain,
|
||||
"verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt",
|
||||
"expected_content": token})
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Domain not found")
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
ok = False
|
||||
if ok:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request)
|
||||
return {"id": domain_id, "verified": ok}
|
||||
|
||||
|
||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
||||
async def delete_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.delete", "domain", domain_id, "", request)
|
||||
return {"status": "deleted", "id": domain_id}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
|
||||
|
||||
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
|
||||
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
|
||||
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
|
||||
(LLM when configured, extractive offline fallback), cached 10 min.
|
||||
|
||||
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
|
||||
See ``docs/V69_Search_Ask_AI.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import semantic_search as sem
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["search-ai"])
|
||||
|
||||
|
||||
def _auth_user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if not has_scope(user.get("_token_scopes") or "read", "read"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: read")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
@router.get("/api/v2/search/hybrid")
|
||||
async def hybrid(request: Request):
|
||||
user = _auth_user(request)
|
||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
raise HTTPException(400, "q is required")
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
|
||||
workspace_id=workspace_id)
|
||||
page = results[offset:offset + limit]
|
||||
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
|
||||
# 5 min). Best-effort is handled by tests calling index_resource directly.
|
||||
resp = JSONResponse({"query": q, "results": page,
|
||||
"total": len(results), "limit": limit, "offset": offset})
|
||||
for k, v in paginate_headers(len(results)).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/v2/search/ask")
|
||||
async def ask_ai(request: Request):
|
||||
user = _auth_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
question = (body.get("question") or body.get("q") or "").strip()
|
||||
if not question:
|
||||
raise HTTPException(400, "question is required")
|
||||
ws = body.get("workspace_id")
|
||||
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
|
||||
out = await sem.ask(question, user, workspace_id)
|
||||
audit_log(user, "search.ask", "search", "", question[:200], request)
|
||||
return {"question": question, "workspace_id": workspace_id, **out}
|
||||
|
||||
|
||||
@router.get("/api/v2/search/index-status")
|
||||
async def index_status(request: Request):
|
||||
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
|
||||
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
|
||||
pages_total = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
|
||||
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
|
||||
return {"indexed_resources": indexed, "vectors": vectors,
|
||||
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
|
||||
"user_id": user.get("id")}
|
||||
@@ -27,9 +27,10 @@ DEFAULT_CONFIG = {
|
||||
"recents": {"visible": True, "order": 3, "show_count": 10},
|
||||
"favorites": {"visible": True, "order": 4, "show_count": 10},
|
||||
"agents": {"visible": True, "order": 5, "show_count": None},
|
||||
"shared": {"visible": True, "order": 6, "show_count": 10},
|
||||
"published": {"visible": True, "order": 7, "show_count": 10},
|
||||
"private": {"visible": True, "order": 8, "show_count": None},
|
||||
"teamspaces": {"visible": True, "order": 6, "show_count": None},
|
||||
"shared": {"visible": True, "order": 7, "show_count": 10},
|
||||
"published": {"visible": True, "order": 8, "show_count": 10},
|
||||
"private": {"visible": True, "order": 9, "show_count": None},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
"""FlowDeck — Sites & public Forms (v6.8.0).
|
||||
|
||||
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
|
||||
password/expiry gating, SEO + view stats, and anonymous collection forms
|
||||
(/f/<token>) with rate limiting, validation and notifications.
|
||||
|
||||
Auth: session cookie first, Bearer token fallback (api_tokens,
|
||||
extension_devices, legacy user_tokens) via api_v2_helpers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password, verify_password
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["sites"])
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
||||
_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$")
|
||||
|
||||
# In-memory rate limiting for anonymous form posts: ip -> (window_start, count).
|
||||
_form_rate: dict[str, tuple[float, int]] = {}
|
||||
_FORM_RATE_MAX = 20
|
||||
_FORM_RATE_WINDOW = 3600.0
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
def _check_slug(slug: str) -> None:
|
||||
if not _SLUG_RE.match(slug or ""):
|
||||
raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.")
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
"""Session-first auth, Bearer fallback. Enforces scope for Bearer tokens."""
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _site_auth_cookie(site_id: int) -> str:
|
||||
return f"site_auth_{site_id}"
|
||||
|
||||
|
||||
def _site_unlocked(request: Request, site: dict) -> bool:
|
||||
if not site.get("password_hash"):
|
||||
return True
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
try:
|
||||
val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400)
|
||||
return val == site["id"]
|
||||
except BadSignature:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _site_expired(site: dict) -> bool:
|
||||
exp = site.get("expires_at")
|
||||
if not exp:
|
||||
return False
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp() < time.time()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None:
|
||||
row = None
|
||||
if slug:
|
||||
row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone()
|
||||
elif host:
|
||||
row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _site_pages(conn, site_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position
|
||||
FROM site_pages sp JOIN pages p ON p.id = sp.page_id
|
||||
WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='')
|
||||
ORDER BY sp.position, p.id""",
|
||||
(site_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _find_site_page(pages: list[dict], ref: str) -> dict | None:
|
||||
ref = (ref or "").strip()
|
||||
if ref.isdigit():
|
||||
for p in pages:
|
||||
if p["id"] == int(ref):
|
||||
return p
|
||||
for p in pages:
|
||||
if p["slug"] == ref:
|
||||
return p
|
||||
# slug with -<id> suffix fallback
|
||||
m = re.search(r"-(\d+)$", ref)
|
||||
if m:
|
||||
for p in pages:
|
||||
if p["id"] == int(m.group(1)):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _render_page_html(page: dict) -> str:
|
||||
"""Render a pages row to HTML (blocks → public renderer, else <pre>)."""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
try:
|
||||
from app.routers import dashboard as _dash
|
||||
blocks = json.loads(page["content"])
|
||||
try:
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
pass
|
||||
titles: dict = {}
|
||||
try:
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as _c:
|
||||
titles = token_labels(_c, page["content"])
|
||||
except Exception:
|
||||
titles = {}
|
||||
return _dash._render_blocks_public(blocks, titles)
|
||||
except Exception:
|
||||
return f"<p>{html.escape(str(page.get('content', '')))}</p>"
|
||||
if page.get("content"):
|
||||
return (
|
||||
"<pre style='white-space:pre-wrap;font-family:system-ui;"
|
||||
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
|
||||
)
|
||||
return "<p style='color:#999'>Empty page.</p>"
|
||||
|
||||
|
||||
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
|
||||
body_html: str, noindex: bool = False) -> str:
|
||||
nav = "".join(
|
||||
f"<a href='/s/{site['slug']}/{p['slug']}'"
|
||||
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
|
||||
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
|
||||
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
|
||||
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
|
||||
for p in pages
|
||||
)
|
||||
robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow"
|
||||
desc = html.escape((site.get("title") or title)[:160])
|
||||
theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff"
|
||||
theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222"
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="robots" content="{robots}">
|
||||
<meta name="description" content="{desc}">
|
||||
<meta property="og:title" content="{html.escape(title)}">
|
||||
<meta property="og:description" content="{desc}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
|
||||
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
|
||||
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
|
||||
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
|
||||
<body><div class="layout"><nav class="nav">
|
||||
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
|
||||
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
|
||||
<main class="main">{body_html}</main></div></body></html>"""
|
||||
|
||||
|
||||
def _track_view(site_id: int) -> None:
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""",
|
||||
(site_id, day),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _form_config(conn, collection_id: int) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
try:
|
||||
cfg = json.loads(row["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {"id": row["id"], "name": row["name"], "config": cfg}
|
||||
|
||||
|
||||
def _check_form_rate(ip: str) -> None:
|
||||
now = time.time()
|
||||
start, count = _form_rate.get(ip, (now, 0))
|
||||
if now - start > _FORM_RATE_WINDOW:
|
||||
_form_rate[ip] = (now, 1)
|
||||
return
|
||||
if count >= _FORM_RATE_MAX:
|
||||
raise HTTPException(429, "Too many submissions. Try again later.")
|
||||
_form_rate[ip] = (start, count + 1)
|
||||
|
||||
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
slug = (body.get("slug") or "").strip().lower() or None
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Root page not found")
|
||||
if not slug:
|
||||
slug = _slugify(page["title"])
|
||||
base, i = slug, 1
|
||||
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
slug = f"{base}-{i}"
|
||||
i += 1
|
||||
else:
|
||||
_check_slug(slug)
|
||||
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
theme = body.get("theme", "dark")
|
||||
if theme not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
custom_domain = (body.get("custom_domain") or "").strip() or None
|
||||
if custom_domain and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
expires_at = body.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
|
||||
expires_at, noindex, analytics_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(slug, root_page_id, body.get("title") or page["title"],
|
||||
theme, custom_domain, expires_at,
|
||||
1 if body.get("noindex") else 0,
|
||||
(body.get("analytics_id") or "")[:120], user["id"]),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
|
||||
(site_id, root_page_id),
|
||||
)
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
|
||||
return JSONResponse(status_code=201, content=row_to_dict(site))
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
resp = JSONResponse([row_to_dict(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
pages = _site_pages(conn, site_id)
|
||||
out = row_to_dict(row)
|
||||
out["pages"] = pages
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
updates: dict = {}
|
||||
if "title" in body:
|
||||
updates["title"] = str(body["title"] or "")[:200]
|
||||
if "theme" in body:
|
||||
if body["theme"] not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
updates["theme"] = body["theme"]
|
||||
if "slug" in body and body["slug"] != site["slug"]:
|
||||
_check_slug(str(body["slug"]).lower())
|
||||
if conn.execute(
|
||||
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = str(body["slug"]).lower()
|
||||
if "custom_domain" in body:
|
||||
dom = (body["custom_domain"] or "").strip() or None
|
||||
if dom and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
updates["custom_domain"] = dom
|
||||
if "expires_at" in body:
|
||||
if body["expires_at"]:
|
||||
try:
|
||||
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
updates["expires_at"] = body["expires_at"]
|
||||
if "noindex" in body:
|
||||
updates["noindex"] = 1 if body["noindex"] else 0
|
||||
if "analytics_id" in body:
|
||||
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
|
||||
if "password" in body:
|
||||
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
|
||||
if updates:
|
||||
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
|
||||
return row_to_dict(site)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "site.delete", "site", site_id, "", request)
|
||||
return {"status": "deleted", "id": site_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
|
||||
(site_id, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
pages = _site_pages(conn, site_id)
|
||||
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
|
||||
return {"site_id": site_id, "pages": pages}
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if page_id == row["root_page_id"]:
|
||||
raise HTTPException(400, "Cannot remove the root page")
|
||||
conn.execute(
|
||||
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
|
||||
return {"status": "removed", "site_id": site_id, "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
rows = conn.execute(
|
||||
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
|
||||
(site_id, days),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
return {"site_id": site_id, "total_views": total,
|
||||
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
|
||||
|
||||
|
||||
# ── Public site rendering ──────────────────────────────────────────────────
|
||||
|
||||
def _public_guard(site: dict, request: Request):
|
||||
if _site_expired(site):
|
||||
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
|
||||
if site.get("password_hash") and not _site_unlocked(request, site):
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<form method="post" action="/s/{site['slug']}/auth">
|
||||
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
|
||||
<input type="password" name="password" placeholder="Password"
|
||||
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
|
||||
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
|
||||
status_code=401,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
pages = _site_pages(conn, site["id"])
|
||||
base = str(request.base_url).rstrip("/")
|
||||
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
|
||||
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
|
||||
]
|
||||
return PlainTextResponse(
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
|
||||
f"{''.join(urls)}</urlset>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
target = _find_site_page(pages, page_ref)
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.post("/s/{slug}/auth")
|
||||
async def public_site_auth(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site:
|
||||
return JSONResponse({"detail": "Site not found"}, status_code=404)
|
||||
if not site.get("password_hash"):
|
||||
return {"status": "public"}
|
||||
ctype = request.headers.get("content-type", "")
|
||||
password = ""
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
password = (await request.json()).get("password", "")
|
||||
except Exception:
|
||||
password = ""
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
except Exception:
|
||||
password = ""
|
||||
if not verify_password(password or "", site["password_hash"] or ""):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
resp = JSONResponse({"status": "unlocked"})
|
||||
resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]),
|
||||
httponly=True, samesite="lax", max_age=86400, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/collections/{collection_id}/form")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
if "enabled" in body:
|
||||
cfg["enabled"] = bool(body["enabled"])
|
||||
for key in ("title", "success_message"):
|
||||
if key in body:
|
||||
cfg[key] = str(body[key] or "")[:300]
|
||||
for key in ("fields", "required", "notify_user_ids"):
|
||||
if key in body and isinstance(body[key], list):
|
||||
cfg[key] = body[key][:50]
|
||||
if "public_token" in body and body["public_token"]:
|
||||
tok = str(body["public_token"])
|
||||
if not _FORM_TOKEN_RE.match(tok):
|
||||
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
|
||||
cfg["public_token"] = tok
|
||||
if cfg.get("enabled") and not cfg.get("public_token"):
|
||||
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
|
||||
conn.execute(
|
||||
"UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(cfg), collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "form.config", "collection", collection_id, "", request)
|
||||
return {"collection_id": collection_id, "form": cfg}
|
||||
|
||||
|
||||
def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
_ = row
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
inputs = ""
|
||||
for name in fields:
|
||||
prop = next((p for p in props if p["name"] == name), None)
|
||||
ptype = (prop or {}).get("prop_type", "text")
|
||||
itype = {"number": "number", "email": "email", "url": "url",
|
||||
"date": "date", "phone": "tel"}.get(ptype, "text")
|
||||
req = "required" if name in required else ""
|
||||
if ptype in ("select", "status") and prop:
|
||||
try:
|
||||
opts = json.loads(prop.get("options_json") or "[]")
|
||||
except Exception:
|
||||
opts = []
|
||||
opts_html = "".join(
|
||||
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
|
||||
elif ptype == "checkbox":
|
||||
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
|
||||
f"{html.escape(name)}</label>")
|
||||
else:
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
|
||||
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
|
||||
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
|
||||
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
|
||||
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
|
||||
</head><body>{chrome}
|
||||
<form method="post" action="/f/{token}">
|
||||
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
|
||||
{inputs}<button>Submit</button></form></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/f/{token}")
|
||||
async def submit_form(request: Request, token: str):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_form_rate(ip or "unknown")
|
||||
ctype = request.headers.get("content-type", "")
|
||||
data: dict = {}
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("__hp"):
|
||||
raise HTTPException(400, "Spam detected")
|
||||
with get_conn() as conn:
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
if not target:
|
||||
# NOTE: return (not raise) — the global 404 handler redirects
|
||||
# non-/api paths to /workspaces, which would turn this into a 200.
|
||||
return JSONResponse({"detail": "Form not found"}, status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
by_name = {p["name"]: p for p in props}
|
||||
fields = cfg.get("fields") or list(by_name)[:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
values: dict = {}
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
raw = data.get(name, "")
|
||||
if prop["prop_type"] == "checkbox":
|
||||
raw = True if raw in (True, "on", "true", "1", "checked") else False
|
||||
if name in required and (raw is None or raw == "" or raw is False):
|
||||
raise HTTPException(400, f"Field required: {name}")
|
||||
values[str(prop["id"])] = raw
|
||||
# Validate via property_types.validate_property_rule
|
||||
try:
|
||||
from app.services.property_types import validate_property_rule
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
ok, _msg = validate_property_rule(
|
||||
prop.get("prop_type", "text"), values.get(str(prop["id"])),
|
||||
prop.get("validation_json") or prop.get("options_json") or "")
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
||||
VALUES (?, ?, ?)""",
|
||||
(coll["id"], title or "Form response", json.dumps(values)),
|
||||
)
|
||||
row_id = cur.lastrowid
|
||||
ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest()
|
||||
conn.execute(
|
||||
"INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)",
|
||||
(coll["id"], row_id, ip_hash),
|
||||
)
|
||||
conn.commit()
|
||||
notify_ids = cfg.get("notify_user_ids") or []
|
||||
# Notify (never throws the submission)
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
for uid in notify_ids[:20]:
|
||||
try:
|
||||
create_notification(int(uid), None, "form_response",
|
||||
f"New response: {coll['name']}",
|
||||
f"{title}", "collection", coll["id"],
|
||||
f"/db/{coll['id']}")
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app.services.automations import fire_event as _fire
|
||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||
except Exception:
|
||||
pass
|
||||
if "application/json" in ctype:
|
||||
return {"status": "ok", "row_id": row_id,
|
||||
"message": cfg.get("success_message") or "Merci !"}
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
# used by tests to reset the anonymous rate limiter
|
||||
def _reset_form_rate() -> None:
|
||||
_form_rate.clear()
|
||||
|
||||
|
||||
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
|
||||
__all__ = ["router", "get_bearer_user"]
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""FlowDeck — Passkeys / WebAuthn (v7.2.0).
|
||||
|
||||
Registration + passwordless login via the ``webauthn`` package (pinned in
|
||||
requirements). Challenges live in a short-lived in-memory store (5 min,
|
||||
single-process — same tradeoff as the SSE rooms). RP ID is derived from the
|
||||
request host. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["webauthn"], prefix="/auth/webauthn")
|
||||
|
||||
# key -> (challenge bytes, expires_at). key = f"reg:{user_id}" | f"login:{login}".
|
||||
_challenges: dict[str, tuple[bytes, float]] = {}
|
||||
_CHALLENGE_TTL = 300.0
|
||||
|
||||
|
||||
def _require_lib():
|
||||
try:
|
||||
import webauthn # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _store_challenge(key: str, challenge: bytes) -> None:
|
||||
_challenges[key] = (challenge, time.time() + _CHALLENGE_TTL)
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
item = _challenges.pop(key, None)
|
||||
if not item:
|
||||
return None
|
||||
challenge, exp = item
|
||||
return challenge if exp > time.time() else None
|
||||
|
||||
|
||||
def _rp(request: Request) -> tuple[str, str]:
|
||||
host = (request.url.hostname or "localhost").split(":")[0]
|
||||
return host, f"{request.url.scheme}://{request.headers.get('host', host)}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def register_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_registration_options, options_to_json
|
||||
user = _session_user(request)
|
||||
rp_id, _origin = _rp(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
exclude = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id, rp_name="FlowDeck", user_name=user.get("login", f"user{user['id']}"),
|
||||
user_id=str(user["id"]).encode(), exclude_credentials=exclude or None)
|
||||
_store_challenge(f"reg:{user['id']}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
rp_id, origin = _rp(request)
|
||||
try:
|
||||
verified = verify_registration_response(
|
||||
credential=body.get("credential") or {},
|
||||
expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin,
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001 — invalid attestation → 400, never 500
|
||||
raise HTTPException(400, f"Registration rejected: {exc}") from None
|
||||
import base64
|
||||
cred_id = base64.urlsafe_b64encode(verified.credential_id).decode().rstrip("=")
|
||||
pubkey = base64.b64encode(bytes(verified.credential_public_key)).decode()
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO webauthn_credentials
|
||||
(user_id, credential_id, public_key, sign_count, name)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(user["id"], cred_id, pubkey, verified.sign_count,
|
||||
str(body.get("name") or "Passkey")[:80]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Credential already registered") from None
|
||||
kid = cur.lastrowid
|
||||
return {"id": kid, "status": "registered"}
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
creds = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
if not creds:
|
||||
raise HTTPException(400, "No passkeys for this account")
|
||||
rp_id, _origin = _rp(request)
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
allow = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in creds]
|
||||
options = generate_authentication_options(rp_id=rp_id, allow_credentials=allow)
|
||||
_store_challenge(f"login:{login}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
stored = conn.execute("SELECT * FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
rp_id, origin = _rp(request)
|
||||
credential = body.get("credential") or {}
|
||||
cred_id = (credential.get("id") or "").rstrip("=")
|
||||
match = next((dict(r) for r in stored if r["credential_id"].rstrip("=") == cred_id), None)
|
||||
if not match:
|
||||
raise HTTPException(401, "Unknown credential")
|
||||
import base64
|
||||
try:
|
||||
verified = verify_authentication_response(
|
||||
credential=credential, expected_challenge=challenge,
|
||||
expected_origin=origin, expected_rp_id=rp_id,
|
||||
credential_public_key=base64.b64decode(match["public_key"]),
|
||||
credential_current_sign_count=match["sign_count"],
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(401, f"Authentication rejected: {exc}") from None
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE webauthn_credentials SET sign_count=? WHERE id=?",
|
||||
(verified.new_sign_count, match["id"]))
|
||||
conn.execute("UPDATE users SET last_login=? WHERE id=?",
|
||||
(str(time.time()), user["id"]))
|
||||
conn.commit()
|
||||
ud = dict(conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone())
|
||||
session = SessionManager.create_session(ud, request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_keys(request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||
" WHERE user_id=? ORDER BY id", (user["id"],)).fetchall()
|
||||
return {"keys": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
(key_id, user["id"]))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Key not found")
|
||||
return {"status": "deleted", "id": key_id}
|
||||
|
||||
|
||||
def _b64url_to_bytes(data: str) -> bytes:
|
||||
import base64
|
||||
padded = data + "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def reset_challenges() -> None:
|
||||
_challenges.clear()
|
||||
|
||||
|
||||
__all__ = ["router", "reset_challenges", "secrets"]
|
||||
@@ -0,0 +1,537 @@
|
||||
"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0).
|
||||
|
||||
Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``.
|
||||
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import wiki
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.notifications import create_notification
|
||||
from app.templating import ENV
|
||||
|
||||
router = APIRouter(tags=["wiki"])
|
||||
|
||||
|
||||
def _esc(value) -> str:
|
||||
return html.escape(str(value))
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not sess or not sess.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin, is_active FROM users WHERE id=?",
|
||||
(sess["id"],)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
raise HTTPException(403, "Account disabled")
|
||||
return sess
|
||||
|
||||
|
||||
def _workspace_id(request: Request) -> int:
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if not wid:
|
||||
raise HTTPException(400, "workspace_id required")
|
||||
try:
|
||||
wid = int(wid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "invalid workspace_id") from None
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM workspaces WHERE id=?", (wid,)).fetchone():
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
return wid
|
||||
|
||||
|
||||
def _teamspace_or_404(teamspace_id: int, user_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Teamspace not found")
|
||||
if not wiki.can_read_teamspace(user_id, teamspace_id):
|
||||
# private teamspace → 404 (not 403), same as restricted collections
|
||||
raise HTTPException(404, "Teamspace not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _page_or_404(page_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, workspace_id, teamspace_id, deleted_at FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row or row["deleted_at"]:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _is_admin(user: dict) -> bool:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
|
||||
def _can_verify(user: dict, page: dict) -> bool:
|
||||
"""Admin, or an editor/owner of the teamspace / workspace holding the page."""
|
||||
if _is_admin(user):
|
||||
return True
|
||||
if page.get("teamspace_id"):
|
||||
return wiki.can_write_teamspace(user["id"], page["teamspace_id"])
|
||||
wid = page.get("workspace_id")
|
||||
if not wid:
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
if owner and owner["owner_id"] == user["id"]:
|
||||
return True
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(wid, user["id"])).fetchone()
|
||||
return bool(member and member["role"] in ("owner", "admin", "editor"))
|
||||
|
||||
|
||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces")
|
||||
async def list_teamspaces(request: Request):
|
||||
user = _user(request)
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if wid:
|
||||
try:
|
||||
wid = int(wid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "invalid workspace_id") from None
|
||||
else:
|
||||
wid = None
|
||||
return {"teamspaces": wiki.list_teamspaces(user["id"], wid)}
|
||||
|
||||
|
||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
||||
async def teamspace_page(teamspace_id: int, request: Request):
|
||||
"""Teamspace detail HTML page — sidebar entry point."""
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT name FROM workspaces WHERE id=?",
|
||||
(ts["workspace_id"],)).fetchone()
|
||||
pages = wiki.teamspace_pages(teamspace_id)
|
||||
collections = wiki.teamspace_collections(teamspace_id)
|
||||
page_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/pages/{p["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>📄</span><span>{_esc(p["title"] or "Untitled")}</span></a>'
|
||||
for p in pages)
|
||||
coll_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/db/{c["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>{_esc(c["icon"] or "🗄️")}</span><span>{_esc(c["name"] or "Untitled")}</span></a>'
|
||||
for c in collections)
|
||||
content_html = f"""
|
||||
<div style="max-width:860px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:26px;display:flex;align-items:center;gap:10px;">
|
||||
{_esc(ts['name'])}{' <span style="font-size:13px;padding:2px 8px;border-radius:10px;background:rgba(76,154,255,.15);color:#4c9aff;">🔒 private</span>' if ts['private'] else ''}
|
||||
</h1>
|
||||
<p style="color:var(--text-dim);">{_esc(ts.get('description') or '')}</p>
|
||||
<div style="display:flex;gap:10px;font-size:12px;color:var(--text-dim);margin-bottom:24px;flex-wrap:wrap;">
|
||||
<span>Workspace: {_esc((ws["name"]) if ws else '')}</span>
|
||||
<span>·</span><span>Role: {_esc(ts['role'])}</span>
|
||||
<span>·</span><span>{len(pages) + len(collections)} items</span>
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:20px 0 8px;">Pages</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{page_rows or '<p style="color:var(--text-dim);font-size:13px;">No pages yet.</p>'}
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:24px 0 8px;">Databases</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{coll_rows or '<p style="color:var(--text-dim);font-size:13px;">No databases yet.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.ts-row:hover{{background:var(--bg-hover);}}
|
||||
</style>"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
page_title=ts["name"],
|
||||
title_prefix="Teamspace",
|
||||
page_icon="🏛️",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/teamspaces")
|
||||
async def create_teamspace(request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
wid = int(body.get("workspace_id") or 0)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(wid, user["id"])).fetchone()
|
||||
allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"])
|
||||
or (member and member["role"] in ("admin", "editor", "owner")))
|
||||
if not allowed:
|
||||
raise HTTPException(403, "Editor role required in the workspace")
|
||||
try:
|
||||
tsid = wiki.create_teamspace(wid, name, user["id"],
|
||||
description=body.get("description") or "",
|
||||
private=bool(body.get("private")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from None
|
||||
audit_log(user, "teamspace.create", "teamspace", tsid, name, request)
|
||||
return JSONResponse(status_code=201, content={"id": tsid, "name": name})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
||||
async def get_teamspace(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id))
|
||||
return ts
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
||||
async def list_members(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m
|
||||
JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""",
|
||||
(teamspace_id,)).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
conn.execute(
|
||||
"""INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?)
|
||||
ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(teamspace_id, member_id, role))
|
||||
conn.commit()
|
||||
audit_log(user, "teamspace.member.set", "teamspace", teamspace_id,
|
||||
f"u{member_id}={role}", request)
|
||||
return {"status": "ok", "user_id": member_id, "role": role}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
|
||||
(teamspace_id, member_id))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Not a member")
|
||||
return {"status": "removed", "user_id": member_id}
|
||||
|
||||
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
||||
async def get_verification(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
return {"verification": wiki.verification(page_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request)
|
||||
return {"verification": out}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def unverify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
if not wiki.unverify_page(page_id):
|
||||
raise HTTPException(404, "Page is not verified")
|
||||
audit_log(user, "page.unverify", "page", page_id, "", request)
|
||||
return {"status": "unverified", "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/verified")
|
||||
async def list_verified(request: Request):
|
||||
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.teamspace_id,
|
||||
v.verified_at, v.expires_at, v.note, u.login
|
||||
FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
LEFT JOIN users u ON u.id = v.verified_by
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL""",
|
||||
(wid,)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if wiki.is_expired(r):
|
||||
continue # badge lapsed → not listed
|
||||
if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"],
|
||||
item["teamspace_id"]):
|
||||
continue # private teamspace → hidden
|
||||
item["active"] = True
|
||||
out.append(item)
|
||||
return {"pages": out}
|
||||
|
||||
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
||||
async def follow_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
now = wiki.toggle_follow(page_id, user["id"])
|
||||
return {"page_id": page_id, "following": now}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
||||
async def list_followers(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
ids = wiki.followers(page_id)
|
||||
if not ids:
|
||||
return {"followers": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})",
|
||||
ids).fetchall()
|
||||
return {"followers": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
try:
|
||||
counts = wiki.toggle_reaction(comment_id, user["id"], emoji)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Comment not found") from None
|
||||
return {"comment_id": comment_id, "reactions": counts}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def list_reactions(comment_id: int, request: Request):
|
||||
_user(request)
|
||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||
|
||||
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
user["id"], days=body.get("days", 30))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "page.guest_share", "page", page_id, share["email"], request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": share["id"], "token": share["token"], "role": share["role"],
|
||||
"expires_at": share["expires_at"], "url": f"/g/{share['token']}"})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def list_guests(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares"
|
||||
" WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall()
|
||||
return {"guests": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
||||
async def revoke_guest(share_id: int, request: Request):
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Guest share not found")
|
||||
conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request)
|
||||
return {"status": "revoked", "id": share_id}
|
||||
|
||||
|
||||
_GUEST_404 = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Link unavailable — FlowDeck</title>
|
||||
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:520px;
|
||||
margin:80px auto;padding:0 20px;color:#1f2328;text-align:center}
|
||||
h1{font-size:20px} p{color:#656d76;line-height:1.6}</style></head><body>
|
||||
<h1>This link is unavailable</h1>
|
||||
<p>It may have expired, been revoked, or never existed.<br>
|
||||
Ask the person who shared it with you for a new link.</p></body></html>"""
|
||||
|
||||
|
||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
||||
async def guest_page(token: str, request: Request):
|
||||
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||
share = wiki.resolve_guest_share(token)
|
||||
if not share:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title, content, created_at, updated_at, deleted_at"
|
||||
" FROM pages WHERE id=?", (share["page_id"],)).fetchone()
|
||||
if not page or page["deleted_at"]:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
wiki.record_view(share["page_id"])
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{page['title']} — FlowDeck guest</title>
|
||||
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
|
||||
margin:40px auto;padding:0 20px;line-height:1.6;color:#1f2328}}
|
||||
.guest-banner{{background:#fff4e5;border:1px solid #ffd8a8;padding:10px 14px;
|
||||
border-radius:8px;margin-bottom:24px;font-size:14px}}
|
||||
pre{{background:#f6f8fa;padding:14px;border-radius:8px;overflow:auto;
|
||||
white-space:pre-wrap;word-break:break-word}}</style></head><body>
|
||||
<div class="guest-banner">You are viewing this page as a guest
|
||||
({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}).
|
||||
Editing is disabled.</div>
|
||||
<h1>{page['title']}</h1><pre>{page['content'] or ''}</pre></body></html>"""
|
||||
|
||||
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/views")
|
||||
async def page_views(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to read analytics")
|
||||
return wiki.view_stats(page_id, days=request.query_params.get("days", 30))
|
||||
|
||||
|
||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/home")
|
||||
async def wiki_home(request: Request):
|
||||
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
recents = conn.execute(
|
||||
"""SELECT id, title, page_icon, updated_at FROM pages
|
||||
WHERE workspace_id=? AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 20""", (wid,)).fetchall()
|
||||
verified = conn.execute(
|
||||
"""SELECT v.page_id, v.verified_at, v.expires_at FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL
|
||||
AND (v.expires_at IS NULL OR v.expires_at > ?)""",
|
||||
(wid, __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).replace(microsecond=0).isoformat()),
|
||||
).fetchall()
|
||||
return {"workspace_id": wid,
|
||||
"teamspaces": wiki.list_teamspaces(user["id"], wid),
|
||||
"verified": [dict(r) for r in verified],
|
||||
"recents": [dict(r) for r in recents]}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
||||
async def sweep_expiry(request: Request):
|
||||
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()[0]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
sent = 0
|
||||
for row in wiki.expiring_verifications(days=7):
|
||||
create_notification(row["owner_id"], None, "page.verification_expiring",
|
||||
"Verification expiring soon",
|
||||
f"“{row['title']}” loses its verified badge on {row['expires_at']}.",
|
||||
resource_type="page", resource_id=row["page_id"])
|
||||
sent += 1
|
||||
return {"notified": sent}
|
||||
|
||||
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
if len(blocks) > 200:
|
||||
raise HTTPException(400, "max 200 blocks per preview")
|
||||
from app.services.wiki_blocks import mmdc_available, render_block
|
||||
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
|
||||
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
|
||||
return {"rendered": out, "mmdc_available": mmdc_available()}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import workers as worker_service
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["workers"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _row_to_api(row) -> dict:
|
||||
d = row_to_dict(row)
|
||||
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
worker_service.validate_code(code)
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
slug = worker_service.unique_slug(body.get("slug") or name)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
|
||||
shared, daily_budget_s, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(slug, body.get("workspace_id"), name, code,
|
||||
(body.get("schedule_cron") or "")[:60],
|
||||
1 if body.get("shared") else 0,
|
||||
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
|
||||
user["id"]))
|
||||
conn.commit()
|
||||
wid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "worker.create", "worker", wid, slug, request)
|
||||
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)).fetchall()
|
||||
resp = JSONResponse([_row_to_api(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
out = _row_to_api(row)
|
||||
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
|
||||
or user.get("is_admin")):
|
||||
out["code_py"] = row["code_py"]
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can update this worker")
|
||||
updates: dict = {}
|
||||
if "name" in body:
|
||||
updates["name"] = str(body["name"] or "")[:200]
|
||||
if "code_py" in body:
|
||||
try:
|
||||
worker_service.validate_code(body["code_py"] or "")
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
updates["code_py"] = body["code_py"] or ""
|
||||
if "schedule_cron" in body:
|
||||
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
|
||||
if "shared" in body:
|
||||
updates["shared"] = 1 if body["shared"] else 0
|
||||
if "daily_budget_s" in body:
|
||||
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
|
||||
if "slug" in body and body["slug"] != row["slug"]:
|
||||
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
|
||||
raise HTTPException(400, "Invalid slug")
|
||||
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
|
||||
(body["slug"], worker_id)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = body["slug"]
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), worker_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
|
||||
return _row_to_api(row)
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can delete this worker")
|
||||
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "worker.delete", "worker", worker_id, "", request)
|
||||
return {"status": "deleted", "id": worker_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/run")
|
||||
async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if (row["created_by"] != user["id"] and not row["shared"]
|
||||
and not user.get("is_admin")):
|
||||
raise HTTPException(403, "Worker is private")
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
out = await loop.run_in_executor(
|
||||
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
|
||||
except HTTPException:
|
||||
raise
|
||||
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
|
||||
raise HTTPException(404, "Worker not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
|
||||
(worker_id, limit)).fetchall()
|
||||
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
return {"workspace_id": wid,
|
||||
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
|
||||
"user_id": user.get("id")}
|
||||
+64
-10
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -25,15 +26,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
|
||||
def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
|
||||
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
|
||||
promouvoir admin."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row or row["role"] != "admin":
|
||||
raise HTTPException(403, "Workspace admin role required")
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
@@ -58,6 +80,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
@@ -68,13 +92,14 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
@@ -84,6 +109,7 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -97,6 +123,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
@@ -664,6 +691,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
@@ -671,12 +699,20 @@ async def list_webhooks(request: Request):
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
@@ -688,6 +724,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
@@ -698,22 +735,39 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
|
||||
sur le titre de la base ou d'une ligne).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
# 404 explicite : le handler global transformerait un HTTPException(404)
|
||||
# en redirection 302 → login pour un chemin HTML.
|
||||
return HTMLResponse(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
|
||||
"<body><h1>404 — Not found</h1></body></html>",
|
||||
status_code=404,
|
||||
)
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
esc = html.escape
|
||||
name = esc(str(coll["name"] or ""))
|
||||
icon = esc(str(coll["icon"] or ""))
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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())
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
@@ -167,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
_parsed = _urlparse(url)
|
||||
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
|
||||
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
@@ -246,6 +254,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
)
|
||||
return f"notified user {user_id}"
|
||||
|
||||
if atype == "slack":
|
||||
url = _secret_value(action.get("webhook_url") or action.get("url") or "")
|
||||
if not url:
|
||||
raise ValueError("slack action requires a webhook_url")
|
||||
_, text = _maybe_convert_prediction(
|
||||
action.get("text") or action.get("message") or "Automation fired", context)
|
||||
return await _post_slack(url, text)
|
||||
|
||||
if atype == "email":
|
||||
to = action.get("to", "")
|
||||
_, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context)
|
||||
_, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context)
|
||||
return await _send_email_action(to, subject, body, context)
|
||||
|
||||
if atype == "forge_issue":
|
||||
provider = (action.get("provider") or "gitea").lower()
|
||||
owner = action.get("owner", "")
|
||||
repo = action.get("repo", "")
|
||||
if not owner or not repo:
|
||||
raise ValueError("forge_issue requires owner + repo")
|
||||
_, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context)
|
||||
_, body = _maybe_convert_prediction(action.get("body", ""), context)
|
||||
return await _create_forge_issue(
|
||||
provider, owner, repo, title, body,
|
||||
labels=action.get("labels") or [],
|
||||
user_id=context.get("created_by"),
|
||||
)
|
||||
|
||||
if atype == "agent_trigger":
|
||||
agent_id = action.get("agent_id")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_trigger requires an agent_id")
|
||||
_, message = _maybe_convert_prediction(action.get("message", ""), context)
|
||||
return await _run_linked_agent(
|
||||
int(agent_id), context.get("created_by") or 1,
|
||||
context.get("workspace_id"), message, context)
|
||||
|
||||
raise ValueError(f"unknown action type: {atype!r}")
|
||||
|
||||
|
||||
@@ -260,6 +305,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
if not auto["enabled"]:
|
||||
return {"status": "skipped", "detail": "automation disabled"}
|
||||
|
||||
# v7.0.0: chained steps take over when present (legacy path otherwise).
|
||||
stepped = await _maybe_run_stepped(auto, trigger_source, context)
|
||||
if stepped is not None:
|
||||
return stepped
|
||||
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
if not evaluate_conditions(auto["condition_json"], props, before):
|
||||
@@ -307,6 +357,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||
if event == "page.updated":
|
||||
try:
|
||||
from app.services.wiki import notify_followers_of_page_update
|
||||
notify_followers_of_page_update(
|
||||
payload.get("page_id"), payload.get("actor_id"),
|
||||
payload.get("title") or "")
|
||||
except Exception: # noqa: BLE001 — notifications are best-effort
|
||||
logger.debug("followers notification failed for page.updated")
|
||||
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as fire_webhooks
|
||||
@@ -320,14 +379,29 @@ async def fire_event(event: str, payload: dict):
|
||||
WHERE trigger_type='event' AND event=? AND enabled=1""",
|
||||
(event,),
|
||||
).fetchall()
|
||||
stepped_ids: set[int] = set()
|
||||
try:
|
||||
with get_conn() as _c:
|
||||
stepped_ids = {r[0] for r in _c.execute(
|
||||
"SELECT DISTINCT automation_id FROM automation_steps").fetchall()}
|
||||
except Exception: # noqa: BLE001 — table missing on very old DBs
|
||||
pass
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
if auto["id"] in stepped_ids:
|
||||
continue # v7.0.0: handled by fire_stepped_event below (no double run)
|
||||
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
|
||||
continue
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
# v7.0.0: step-based automations (multi-trigger any/all, chains).
|
||||
try:
|
||||
await fire_stepped_event(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("stepped dispatch failed for %s", event)
|
||||
|
||||
|
||||
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
|
||||
|
||||
@@ -409,6 +483,353 @@ async def automation_scheduler():
|
||||
await run_automation(auto["id"], "cron", context)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Cron automation %s errored", auto["id"])
|
||||
# v7.0.0: workers on a cron schedule share the same 60s loop.
|
||||
try:
|
||||
from app.services.workers import run_due_workers
|
||||
await run_due_workers()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("worker cron iteration failed")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("automation_scheduler iteration failed")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
|
||||
|
||||
STEP_KINDS = ("trigger", "condition", "delay", "action")
|
||||
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
|
||||
"slack", "email", "forge_issue", "agent_trigger")
|
||||
ALL_MODE_WINDOW_S = 300.0
|
||||
|
||||
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
|
||||
_ALL_PENDING: dict[int, dict[str, float]] = {}
|
||||
|
||||
|
||||
def reset_all_pending() -> None:
|
||||
"""Test helper: clear the mode=all arrival window."""
|
||||
_ALL_PENDING.clear()
|
||||
|
||||
|
||||
def _secret_value(stored: str | None) -> str:
|
||||
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
decrypted = decrypt_secret(stored)
|
||||
if decrypted:
|
||||
return decrypted
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
|
||||
return stored
|
||||
return ""
|
||||
|
||||
|
||||
def validate_step(kind: str, config: dict) -> None:
|
||||
"""Validate a step payload. Raises ValueError with a human message."""
|
||||
from fastapi import HTTPException
|
||||
if kind not in STEP_KINDS:
|
||||
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
|
||||
config = config or {}
|
||||
if kind == "trigger":
|
||||
if not config.get("event"):
|
||||
raise HTTPException(400, "trigger step requires an event")
|
||||
elif kind == "condition":
|
||||
if config.get("op", "eq") not in COND_OPS:
|
||||
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
|
||||
elif kind == "delay":
|
||||
try:
|
||||
seconds = int(config.get("seconds", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "delay step requires integer seconds") from None
|
||||
if seconds < 0 or seconds > 86400:
|
||||
raise HTTPException(400, "delay seconds must be 0..86400")
|
||||
elif kind == "action":
|
||||
if config.get("type") not in STEP_ACTION_TYPES:
|
||||
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
|
||||
|
||||
|
||||
def get_steps(automation_id: int) -> list[dict]:
|
||||
"""Ordered steps of an automation (empty when legacy single-mode)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
|
||||
(automation_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["config"] = json.loads(d.get("config_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
d["config"] = {}
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
|
||||
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
|
||||
"delay": [], "action": []}
|
||||
for s in steps:
|
||||
if s.get("kind") in grouped:
|
||||
grouped[s["kind"]].append(s)
|
||||
return grouped
|
||||
|
||||
|
||||
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
|
||||
automation_collection_id: int | None) -> bool:
|
||||
if step_cfg.get("event") != event:
|
||||
return False
|
||||
want_coll = step_cfg.get("collection_id") or automation_collection_id
|
||||
if want_coll and payload.get("collection_id") != want_coll:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
|
||||
context: dict) -> dict:
|
||||
"""Execute a chained automation. Records one run row with per-step detail."""
|
||||
grouped = _steps_by_kind(steps)
|
||||
props = context.get("properties")
|
||||
before = context.get("before_properties")
|
||||
# Legacy single condition still applies on top of step conditions.
|
||||
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
|
||||
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "condition not met"}
|
||||
for cond in grouped["condition"]:
|
||||
cfg = cond.get("config") or {}
|
||||
if not match_condition_props(props, before, {
|
||||
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
|
||||
"value": cfg.get("value")}):
|
||||
_save_run(auto["id"], trigger_source, "skipped",
|
||||
f"step condition not met: {cfg.get('property')}",
|
||||
context.get("collection_id"), context.get("page_id"))
|
||||
return {"status": "skipped", "detail": "step condition not met"}
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["automation_name"] = auto["name"]
|
||||
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
|
||||
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
|
||||
results = []
|
||||
try:
|
||||
for step in ordered:
|
||||
kind = step.get("kind")
|
||||
cfg = step.get("config") or {}
|
||||
if kind in ("trigger", "condition"):
|
||||
continue
|
||||
if kind == "delay":
|
||||
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
results.append(f"delay {cfg.get('seconds', 0)}s")
|
||||
elif kind == "action":
|
||||
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
|
||||
trigger_source)
|
||||
results.append(summary)
|
||||
detail = "; ".join(results) or "no steps executed"
|
||||
_save_run(auto["id"], trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": auto["id"], "name": auto["name"],
|
||||
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"), "detail": detail})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
|
||||
_save_run(auto["id"], trigger_source, "error", str(exc),
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
|
||||
"""Run via steps when the automation has any; None → use legacy path."""
|
||||
steps = get_steps(auto["id"])
|
||||
if not steps:
|
||||
return None
|
||||
return await _run_with_steps(auto, steps, trigger_source, context)
|
||||
|
||||
|
||||
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
|
||||
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.* FROM automations a
|
||||
JOIN automation_steps s ON s.automation_id = a.id
|
||||
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
|
||||
).fetchall()
|
||||
matched = []
|
||||
for row in rows:
|
||||
auto = dict(row)
|
||||
steps = get_steps(auto["id"])
|
||||
triggers = [s for s in steps if s.get("kind") == "trigger"]
|
||||
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
|
||||
auto.get("collection_id")) for t in triggers):
|
||||
matched.append((auto, triggers))
|
||||
return matched
|
||||
|
||||
|
||||
async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
"""Dispatch ``event`` to step-based automations (mode any/all).
|
||||
|
||||
Called from :func:`fire_event` after the legacy matcher. Unknown events
|
||||
(not in the webhook catalogue) still work here — steps are independent
|
||||
from outbound webhooks.
|
||||
"""
|
||||
now = time.time()
|
||||
for auto, triggers in _match_stepped_automations(event, payload):
|
||||
# Skip automations already handled by the legacy matcher to avoid
|
||||
# double runs (legacy = trigger_type event + no steps).
|
||||
if not get_steps(auto["id"]):
|
||||
continue
|
||||
mode = (auto.get("trigger_mode") or "any").lower()
|
||||
if mode == "all":
|
||||
pending = _ALL_PENDING.setdefault(auto["id"], {})
|
||||
pending[event] = now
|
||||
# Expire arrivals outside the window.
|
||||
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
|
||||
del pending[ev]
|
||||
wanted = {t.get("config", {}).get("event") for t in triggers}
|
||||
if not wanted <= set(pending):
|
||||
continue
|
||||
_ALL_PENDING.pop(auto["id"], None)
|
||||
context = dict(payload)
|
||||
context["event"] = event
|
||||
await run_automation(auto["id"], "event", context)
|
||||
|
||||
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
return f"slack → ({resp.status_code})"
|
||||
|
||||
|
||||
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
|
||||
from app.services import mailer
|
||||
address = (to or "").strip()
|
||||
if address.startswith("user:"):
|
||||
try:
|
||||
uid = int(address.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raise ValueError(f"bad email target: {to!r}") from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
|
||||
address = (row["email"] if row and row["email"] else "")
|
||||
if not address:
|
||||
raise ValueError(f"user {uid} has no email")
|
||||
if not address:
|
||||
address = None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT email FROM users WHERE id=?",
|
||||
(context.get("created_by") or 1,)).fetchone()
|
||||
if row and row["email"]:
|
||||
address = row["email"]
|
||||
if not address:
|
||||
return "email skipped (no recipient)"
|
||||
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
|
||||
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
|
||||
|
||||
|
||||
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
body: str, labels: list | None = None,
|
||||
user_id: int | None = None) -> str:
|
||||
token = ""
|
||||
if user_id:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
|
||||
(user_id, provider)).fetchone()
|
||||
token = (row["access_token"] if row else "") or ""
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json"},
|
||||
json={"title": title, "body": body,
|
||||
"labels": labels or []} if labels else {"title": title, "body": body},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"github returned HTTP {resp.status_code}")
|
||||
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
|
||||
# gitea (default)
|
||||
from app.services.gitea_client import GiteaClient
|
||||
gitea = GiteaClient(user_token=token or None)
|
||||
issue = await gitea.create_issue(owner, repo, title, body)
|
||||
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
|
||||
|
||||
|
||||
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
|
||||
message: str, context: dict) -> str:
|
||||
from app.services.agent_engine import AgentEngine
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise ValueError(f"agent {agent_id} not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user_id,
|
||||
f"Automation: {context.get('automation_name', 'run')}",
|
||||
json.dumps({"workspace_id": workspace_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
objective = ((agent["system_instructions"] or "").strip()
|
||||
or f"Exécute l'agent « {agent['name']} ».")
|
||||
if message:
|
||||
objective = f"{objective}\n\n{message}"
|
||||
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
|
||||
final = ""
|
||||
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
|
||||
pass
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
|
||||
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
||||
final = (row["content"][:300] if row and row["content"] else "")
|
||||
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
|
||||
|
||||
|
||||
# ── v7.0.0 native DB button ────────────────────────────────────────────────
|
||||
|
||||
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
|
||||
user_id: int) -> dict:
|
||||
"""Run the automation linked to a ``button`` property cell."""
|
||||
with get_conn() as conn:
|
||||
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(int(prop_ref), collection_id)).fetchone()
|
||||
else:
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(collection_id, prop_ref)).fetchone()
|
||||
if not prop:
|
||||
raise ValueError("button property not found")
|
||||
prop = dict(prop)
|
||||
if prop.get("prop_type") != "button":
|
||||
raise ValueError("property is not a button")
|
||||
auto_id = prop.get("button_automation_id")
|
||||
if not auto_id:
|
||||
raise ValueError("button has no linked automation")
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("row not found")
|
||||
context = get_page_context(row_id, collection_id)
|
||||
context["created_by"] = user_id
|
||||
return await run_automation(auto_id, "button", context)
|
||||
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
"""FlowDeck — external calendar sync (v7.1.0).
|
||||
|
||||
Bidirectional sync between a collection (date property) and an external
|
||||
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
|
||||
extra dependency). Tokens are Fernet-encrypted at rest.
|
||||
|
||||
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
|
||||
Conflicts (both sides changed since ``last_sync``): last-write-wins +
|
||||
in-app ``calendar.conflict`` notification (manual edit resolves).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS = ("google", "caldav")
|
||||
SYNC_LOOKBACK_DAYS = 30
|
||||
SYNC_LOOKAHEAD_DAYS = 90
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
"""Raised when the remote calendar cannot be reached/authorized."""
|
||||
|
||||
|
||||
# ── links ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encrypt_tokens(creds: dict) -> str:
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
return encrypt_secret(json.dumps(creds or {}))
|
||||
|
||||
|
||||
def _decrypt_tokens(tokens_enc: str) -> dict:
|
||||
if not tokens_enc:
|
||||
return {}
|
||||
try:
|
||||
from app.services.sso_provisioning import decrypt_secret
|
||||
raw = decrypt_secret(tokens_enc)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try: # legacy plaintext (tests)
|
||||
data = json.loads(tokens_enc)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def save_link(user_id: int, provider: str, collection_id: int,
|
||||
credentials: dict, calendar_id: str = "primary",
|
||||
date_property: str = "") -> dict:
|
||||
if provider not in PROVIDERS:
|
||||
raise ValueError(f"provider must be google|caldav, got {provider!r}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?",
|
||||
(collection_id,)).fetchone():
|
||||
raise ValueError("collection not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO calendar_links
|
||||
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
|
||||
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
|
||||
date_property=excluded.date_property""",
|
||||
(user_id, provider, _encrypt_tokens(credentials),
|
||||
calendar_id or "primary", collection_id, date_property or ""))
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
|
||||
(user_id, provider, calendar_id or "primary")).fetchone()
|
||||
_ = cur
|
||||
out = dict(row)
|
||||
out.pop("tokens_enc", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_links(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, user_id, provider, calendar_id, collection_id,"
|
||||
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
|
||||
" ORDER BY id", (user_id,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_link(user_id: int, link_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
|
||||
(link_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def _load_link(link_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
|
||||
|
||||
def _remote_event(eid: str, title: str, start: str, description: str = "",
|
||||
updated: str = "") -> dict:
|
||||
return {"id": str(eid), "title": title or "Untitled", "start": start,
|
||||
"description": description or "", "updated": updated or ""}
|
||||
|
||||
|
||||
async def google_list_events(tokens: dict, calendar_id: str,
|
||||
time_min: str, time_max: str) -> list[dict]:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
out = []
|
||||
for item in resp.json().get("items", []):
|
||||
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
|
||||
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
|
||||
start, item.get("description", ""),
|
||||
item.get("updated", "")))
|
||||
return out
|
||||
|
||||
|
||||
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
remote_id: str = "") -> str:
|
||||
access = tokens.get("access_token", "")
|
||||
if not access:
|
||||
raise SyncError("google link has no access_token — relink the calendar")
|
||||
body = {"summary": event.get("title", ""),
|
||||
"description": event.get("description", ""),
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
else:
|
||||
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
|
||||
json=body)
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"google returned HTTP {resp.status_code}")
|
||||
return str(resp.json().get("id", remote_id or ""))
|
||||
|
||||
|
||||
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop><D:getetag/><C:calendar-data/></D:prop>
|
||||
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{start}" end="{end}"/>
|
||||
</C:comp-filter></C:comp-filter></C:filter>
|
||||
</C:calendar-query>"""
|
||||
|
||||
|
||||
def _parse_caldav_events(xml_text: str) -> list[dict]:
|
||||
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
events = []
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
|
||||
for resp in root.findall("D:response", ns):
|
||||
href = resp.findtext("D:href", default="", namespaces=ns)
|
||||
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
|
||||
if data_el is None or not data_el.text:
|
||||
continue
|
||||
ics = data_el.text
|
||||
uid = re.search(r"^UID:(.+)$", ics, re.M)
|
||||
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
|
||||
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
|
||||
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
|
||||
events.append(_remote_event(
|
||||
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
|
||||
summary.group(1).strip() if summary else "Untitled",
|
||||
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
|
||||
desc.group(1).strip() if desc else ""))
|
||||
return events
|
||||
|
||||
|
||||
def _ics_to_date(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 8 and value[:8].isdigit():
|
||||
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
|
||||
return value[:10]
|
||||
|
||||
|
||||
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
day = (date or "")[:10].replace("-", "")
|
||||
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
|
||||
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
|
||||
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
|
||||
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
|
||||
url = creds.get("url", "")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("caldav rejected credentials")
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return _parse_caldav_events(resp.text)
|
||||
|
||||
|
||||
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
|
||||
url = (creds.get("url", "") or "").rstrip("/")
|
||||
if not url:
|
||||
raise SyncError("caldav link needs a calendar url")
|
||||
auth = (creds.get("username", ""), creds.get("password", ""))
|
||||
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
|
||||
href = f"{url}/{uid}.ics" if not remote_id else (
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
return uid
|
||||
|
||||
|
||||
# ── mapping + sync ─────────────────────────────────────────────────────────
|
||||
|
||||
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
|
||||
props = conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()
|
||||
if wanted:
|
||||
for p in props:
|
||||
if str(p["id"]) == str(wanted) or p["name"] == wanted:
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
for p in props:
|
||||
# prop_type lives in the row; fetch full rows only when needed
|
||||
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
|
||||
(p["id"],)).fetchone()
|
||||
if full and full["prop_type"] == "date":
|
||||
return str(p["id"]), p["name"]
|
||||
return None
|
||||
|
||||
|
||||
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
raw = values.get(prop_id, values.get(prop_name, ""))
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("date") or raw.get("value") or ""
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
text = str(value).strip()
|
||||
try:
|
||||
if text.endswith("Z"):
|
||||
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp()
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def _window() -> tuple[str, str]:
|
||||
now = datetime.now(UTC)
|
||||
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
|
||||
return start, end
|
||||
|
||||
|
||||
async def sync_link(link_id: int) -> dict:
|
||||
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
|
||||
link = _load_link(link_id)
|
||||
if not link:
|
||||
raise ValueError("link not found")
|
||||
creds = _decrypt_tokens(link.get("tokens_enc") or "")
|
||||
collection_id = link.get("collection_id")
|
||||
if not collection_id:
|
||||
raise ValueError("link has no collection")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
|
||||
tmin, tmax = _window()
|
||||
if link["provider"] == "google":
|
||||
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
|
||||
tmin, tmax)
|
||||
else:
|
||||
remote = await caldav_list_events(creds, tmin, tmax)
|
||||
|
||||
last_sync = _to_epoch(link.get("last_sync"))
|
||||
pulled = pushed = conflicts = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at,"
|
||||
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
|
||||
seen_remote: set[str] = set()
|
||||
touched: set[int] = set() # rows written by this pull pass — never push back
|
||||
|
||||
for ev in remote:
|
||||
eid = ev.get("id", "")
|
||||
if not eid:
|
||||
continue
|
||||
seen_remote.add(eid)
|
||||
day = (ev.get("start") or "")[:10]
|
||||
if eid not in local:
|
||||
values: dict = {}
|
||||
values[prop_id] = day
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
|
||||
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, position, property_values_json, external_event_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(collection_id, ev.get("title") or "Untitled", max_pos,
|
||||
json.dumps(values), eid))
|
||||
pulled += 1
|
||||
continue
|
||||
row = local[eid]
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
local_day = _row_date(values, prop_id, prop_name)[:10]
|
||||
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
|
||||
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
|
||||
if remote_newer and local_dirty and local_day and day and local_day != day:
|
||||
# Conflict: both sides moved → last-write-wins + notify.
|
||||
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
conflicts += 1
|
||||
_notify_conflict(conn, link, row, ev)
|
||||
elif day and day != local_day:
|
||||
values[prop_id] = day
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?,"
|
||||
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), row["id"]))
|
||||
touched.add(row["id"])
|
||||
pulled += 1
|
||||
|
||||
# Push local changes (created locally or edited after last_sync).
|
||||
for xid, row in local.items():
|
||||
if row["id"] in touched:
|
||||
continue
|
||||
if xid in seen_remote:
|
||||
# Edited locally since last sync and remote unchanged → push.
|
||||
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
|
||||
await _push(link, creds, row, prop_id, prop_name, xid)
|
||||
pushed += 1
|
||||
continue
|
||||
# Remote deleted the event → drop the local id (keep the row).
|
||||
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
|
||||
(row["id"],))
|
||||
# Rows never linked and recently touched → create remotely.
|
||||
fresh = conn.execute(
|
||||
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
|
||||
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
|
||||
(collection_id,)).fetchall()
|
||||
for row in fresh:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
day = _row_date(values, prop_id, prop_name)[:10]
|
||||
if not day:
|
||||
continue
|
||||
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
|
||||
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
|
||||
(new_id, row["id"]))
|
||||
pushed += 1
|
||||
|
||||
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(link_id,))
|
||||
conn.commit()
|
||||
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
|
||||
|
||||
|
||||
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
|
||||
prop_name: str, remote_id: str) -> str:
|
||||
try:
|
||||
values = json.loads(row.get("property_values_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
values = {}
|
||||
event = {"title": row.get("title") or "Untitled",
|
||||
"start": _row_date(values, prop_id, prop_name),
|
||||
"description": ""}
|
||||
if link["provider"] == "google":
|
||||
return await google_push_event(creds, link.get("calendar_id") or "primary",
|
||||
event, remote_id)
|
||||
return await caldav_push_event(creds, event, remote_id)
|
||||
|
||||
|
||||
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
create_notification(
|
||||
link["user_id"], link["user_id"], "calendar",
|
||||
"Calendar sync conflict",
|
||||
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
|
||||
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
|
||||
resource_type="collection", resource_id=link.get("collection_id") or 0,
|
||||
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
|
||||
except Exception: # noqa: BLE001 — notify must never break sync
|
||||
pass
|
||||
|
||||
|
||||
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
|
||||
"""Background loop: sync every link with a collection (15 min default)."""
|
||||
while True:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
|
||||
).fetchall()]
|
||||
for link_id in ids:
|
||||
try:
|
||||
await sync_link(link_id)
|
||||
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
|
||||
logger.debug("calendar sync link %s failed: %s", link_id, exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("calendar_sync_scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
def freebusy(collection_id: int, date_from: str, date_to: str,
|
||||
date_property: str = "") -> dict:
|
||||
"""Busy/free weekdays in [date_from, date_to] (day granularity).
|
||||
|
||||
Expands recurrence rules server-side (``recurrence.expand_rule``).
|
||||
"""
|
||||
from app.services import recurrence as _rec
|
||||
try:
|
||||
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
|
||||
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError("use YYYY-MM-DD dates") from None
|
||||
if end < start or (end - start).days > 370:
|
||||
raise ValueError("range must be 1..370 days")
|
||||
with get_conn() as conn:
|
||||
date_prop = _date_prop_id(conn, collection_id, date_property)
|
||||
if not date_prop:
|
||||
raise ValueError("collection has no date property")
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,)).fetchall()
|
||||
busy: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
values = json.loads(r["property_values_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
base = _row_date(values, prop_id, prop_name)
|
||||
if not base:
|
||||
continue
|
||||
rec = (values.get("__recurrence__") or {})
|
||||
rule = rec.get(prop_id) or rec.get(prop_name)
|
||||
if rule:
|
||||
try:
|
||||
for occ in _rec.expand_rule(
|
||||
base, rule, start.isoformat(), end.isoformat()):
|
||||
busy.add(occ[:10])
|
||||
except Exception: # noqa: BLE001 — bad rule, use base date only
|
||||
busy.add(base[:10])
|
||||
else:
|
||||
if start.isoformat() <= base[:10] <= end.isoformat():
|
||||
busy.add(base[:10])
|
||||
days, free = [], []
|
||||
day = start
|
||||
while day <= end:
|
||||
iso = day.isoformat()
|
||||
days.append({"date": iso, "busy": iso in busy,
|
||||
"weekend": day.weekday() >= 5})
|
||||
if iso not in busy and day.weekday() < 5:
|
||||
free.append(iso)
|
||||
day += timedelta(days=1)
|
||||
return {"collection_id": collection_id, "from": start.isoformat(),
|
||||
"to": end.isoformat(), "days": days, "free_weekdays": free}
|
||||
@@ -639,6 +639,10 @@ def blocks_to_html(blocks: list) -> str:
|
||||
icon = b.get("icon") or "💡"
|
||||
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
|
||||
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
|
||||
elif t in ("mermaid", "equation_inline", "progress"):
|
||||
# v7.3.0 blocks — server-rendered so export embeds real content
|
||||
from app.services.wiki_blocks import render_block
|
||||
parts.append(render_block(b))
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
|
||||
@@ -73,6 +73,33 @@ class GiteaClient:
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
async def get_repo_info(self, owner: str, repo: str) -> dict:
|
||||
"""Repository metadata for an unfurl card (owner/name/branch/…)."""
|
||||
cache_key = f"repo_info:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
info = resp.json()
|
||||
repo_info = {
|
||||
"id": info.get("id"),
|
||||
"name": info.get("name"),
|
||||
"owner": (info.get("owner") or {}).get("login", owner),
|
||||
"full_name": info.get("full_name") or f"{owner}/{repo}",
|
||||
"clone_url": info.get("clone_url", ""),
|
||||
"html_url": info.get("html_url", ""),
|
||||
"default_branch": info.get("default_branch", "main"),
|
||||
"description": info.get("description") or "",
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
async def get_user_orgs(self) -> list[dict]:
|
||||
cache_key = "user_orgs"
|
||||
cached = self._cached(cache_key)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
|
||||
|
||||
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
|
||||
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
|
||||
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
|
||||
|
||||
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
|
||||
``transcript`` (client-side transcription). Nothing here requires new pip
|
||||
dependencies. See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
|
||||
MAX_AUDIO_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
class TranscriptionUnavailable(RuntimeError):
|
||||
"""Raised when no transcription backend is configured."""
|
||||
|
||||
|
||||
def meetings_dir() -> Path:
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
d = root / "uploads" / "meetings"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def save_transcript(page_id: int, transcript: str, language: str = "fr",
|
||||
audio_path: str = "") -> int:
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise ValueError("page not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
|
||||
VALUES (?,?,?,?)""",
|
||||
(page_id, audio_path, transcript or "", language or "fr"))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
|
||||
|
||||
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
|
||||
(command must print or be adapted — stdout is preferred). Raises
|
||||
:class:`TranscriptionUnavailable` when unconfigured.
|
||||
"""
|
||||
cmd_template = os.environ.get("STT_COMMAND", "").strip()
|
||||
if not cmd_template:
|
||||
raise TranscriptionUnavailable(
|
||||
"no transcription backend (set STT_COMMAND or POST a manual transcript)")
|
||||
if shutil.which(cmd_template.split()[0]) is None:
|
||||
raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}")
|
||||
try:
|
||||
proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured
|
||||
capture_output=True, text=True, timeout=600)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TranscriptionUnavailable("transcription timed out") from exc
|
||||
text = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0 or not text:
|
||||
raise TranscriptionUnavailable(
|
||||
f"transcription failed: {(proc.stderr or '')[:300]}")
|
||||
return text
|
||||
|
||||
|
||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
|
||||
"""Summarize a stored transcript + fire ``meeting.summarized``.
|
||||
|
||||
Returns {transcript_id, summary, offline}. Emits the automation event so
|
||||
custom agents (update tracker, post recap, file tickets) trigger.
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("transcript not found")
|
||||
tr = dict(row)
|
||||
if not (tr.get("transcript") or "").strip():
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
svc = AIWritingService(user_id=user_id)
|
||||
res = await svc.run("summarize", context=tr["transcript"])
|
||||
summary = (res.get("text") or "").strip() if res.get("ok") else ""
|
||||
if not summary:
|
||||
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
|
||||
(summary, transcript_id))
|
||||
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
|
||||
conn.commit()
|
||||
try:
|
||||
from app.services.automations import fire_event
|
||||
await fire_event("meeting.summarized", {
|
||||
"page_id": tr["page_id"] if page else 0,
|
||||
"transcript_id": transcript_id,
|
||||
"language": tr.get("language") or "fr",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
|
||||
logger.debug("meeting.summarized dispatch failed: %s", exc)
|
||||
return {"transcript_id": transcript_id, "summary": summary,
|
||||
"offline": bool(res.get("offline"))}
|
||||
@@ -103,6 +103,34 @@ def parse_og(body: str, url: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
async def _get_checked(client, url: str, headers: dict):
|
||||
"""GET avec re-vérification de l'hôte à CHAQUE saut de redirection (A12 SSRF).
|
||||
|
||||
`follow_redirects=True` laisserait une URL publique rediriger vers
|
||||
169.254.169.254 / localhost — la garde doit donc tourner à chaque hop.
|
||||
"""
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
parsed = urlparse(current)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise ValueError(f"hôte non autorisé: {parsed.hostname!r}")
|
||||
r = await client.get(current, headers=headers, follow_redirects=False)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
loc = r.headers.get("location")
|
||||
if not loc:
|
||||
return r
|
||||
current = urljoin(current, loc)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r
|
||||
raise ValueError("trop de redirections")
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
@@ -121,12 +149,14 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
kwargs = {"timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
resp = await _get_checked(client, src, headers)
|
||||
except ValueError:
|
||||
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
logger.debug("og fetch failed for %s: %s", src, exc)
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""FlowDeck — semantic (vector) search + Ask AI (v6.9.0).
|
||||
|
||||
Hybrid retrieval = lexical (FTS5/LIKE via :mod:`app.services.search`) fused
|
||||
with vector cosine similarity via Reciprocal Rank Fusion, then filtered
|
||||
through :class:`PermissionManager` so unauthorized chunks never surface
|
||||
(and never enter an LLM prompt).
|
||||
|
||||
Vectors use a dependency-free **hashed TF** encoder (``hash-256``): token →
|
||||
``md5 % 256`` with L2 normalization. Deterministic, offline-first, good
|
||||
enough for recall on small workspaces; the ``embed_texts`` entry point is
|
||||
pluggable should an LLM ``/embeddings`` provider be wired later.
|
||||
See ``docs/V69_Search_Ask_AI.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIM = 256
|
||||
MODEL = "hash-256"
|
||||
CHUNK_SIZE = 1200
|
||||
CHUNK_OVERLAP = 150
|
||||
MAX_CHUNKS_PER_RESOURCE = 50
|
||||
RRF_K = 60
|
||||
|
||||
_TOKEN_RE = re.compile(r"[\wÀ-ÿ]+", flags=re.UNICODE)
|
||||
|
||||
# Ask cache: (question_hash, workspace_id, user_id) -> (expires_at, payload)
|
||||
_ask_cache: dict[tuple[str, int | None, int], tuple[float, dict]] = {}
|
||||
_ASK_CACHE_TTL = 600.0
|
||||
|
||||
# Ask rate limit: user_id -> (window_start, count)
|
||||
_ask_rate: dict[int, tuple[float, int]] = {}
|
||||
_ASK_RATE_MAX = 30
|
||||
_ASK_RATE_WINDOW = 60.0
|
||||
|
||||
|
||||
# ── text extraction & chunking ─────────────────────────────────────────────
|
||||
|
||||
def _blocks_to_text(blocks) -> list[str]:
|
||||
parts: list[str] = []
|
||||
|
||||
def _walk(items) -> None:
|
||||
for b in items or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
for key in ("content", "text", "title"):
|
||||
val = b.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
parts.append(val.strip())
|
||||
break
|
||||
children = b.get("children")
|
||||
if isinstance(children, list):
|
||||
_walk(children)
|
||||
|
||||
_walk(blocks if isinstance(blocks, list) else [])
|
||||
return parts
|
||||
|
||||
|
||||
def extract_page_text(content: str | None, content_format: str | None) -> str:
|
||||
"""Full searchable text of a ``pages`` row (all blocks, recursive)."""
|
||||
if not content:
|
||||
return ""
|
||||
if (content_format or "blocks") == "blocks":
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
return "\n".join(_blocks_to_text(blocks))
|
||||
except Exception:
|
||||
return content
|
||||
return content
|
||||
|
||||
|
||||
def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
|
||||
"""Split text into overlapping chunks (char-based, word-boundary aware)."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= size:
|
||||
return [text]
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + size, len(text))
|
||||
if end < len(text):
|
||||
space = text.rfind(" ", start, end)
|
||||
if space > start + size // 2:
|
||||
end = space
|
||||
chunks.append(text[start:end].strip())
|
||||
if end >= len(text):
|
||||
break
|
||||
start = max(end - overlap, start + 1)
|
||||
if len(chunks) >= MAX_CHUNKS_PER_RESOURCE:
|
||||
break
|
||||
return [c for c in chunks if c]
|
||||
|
||||
|
||||
# ── hashed-TF embeddings ───────────────────────────────────────────────────
|
||||
|
||||
def _tokens(text: str) -> list[str]:
|
||||
return [t.lower() for t in _TOKEN_RE.findall(text or "") if t]
|
||||
|
||||
|
||||
def embed_text(text: str, dim: int = DIM) -> bytes:
|
||||
"""Deterministic L2-normalized hashed-TF vector, struct-packed float32."""
|
||||
vec = [0.0] * dim
|
||||
for tok in _tokens(text):
|
||||
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim
|
||||
vec[idx] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0:
|
||||
vec = [v / norm for v in vec]
|
||||
return struct.pack(f"<{dim}f", *vec)
|
||||
|
||||
|
||||
def embed_texts(texts: list[str], dim: int = DIM) -> list[bytes]:
|
||||
"""Batch entry point (pluggable: LLM /embeddings can replace hashing)."""
|
||||
return [embed_text(t, dim) for t in texts]
|
||||
|
||||
|
||||
def cosine(a: bytes, b: bytes, dim: int = DIM) -> float:
|
||||
"""Cosine similarity of two packed normalized vectors (== dot product)."""
|
||||
try:
|
||||
va = struct.unpack(f"<{dim}f", a)
|
||||
vb = struct.unpack(f"<{dim}f", b)
|
||||
except struct.error:
|
||||
return 0.0
|
||||
return sum(x * y for x, y in zip(va, vb, strict=True))
|
||||
|
||||
|
||||
# ── indexing ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _resource_text(conn, resource_type: str, resource_id: int) -> str | None:
|
||||
"""Return indexable text, or None when the resource must not be indexed."""
|
||||
if resource_type == "page":
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages "
|
||||
"WHERE id=? AND (deleted_at IS NULL OR deleted_at='') "
|
||||
"AND COALESCE(search_excluded, 0)=0",
|
||||
(resource_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
body = extract_page_text(row["content"], row["content_format"])
|
||||
return f"{row['title'] or ''}\n{body}".strip()
|
||||
if resource_type == "collection":
|
||||
row = conn.execute(
|
||||
"SELECT name, description FROM collections WHERE id=?", (resource_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return f"{row['name'] or ''}\n{row['description'] or ''}".strip()
|
||||
return None
|
||||
|
||||
|
||||
def index_resource(resource_type: str, resource_id: int) -> int:
|
||||
"""(Re)index one resource. Returns the number of chunks stored."""
|
||||
with get_conn() as conn:
|
||||
text = _resource_text(conn, resource_type, resource_id)
|
||||
conn.execute(
|
||||
"DELETE FROM semantic_embeddings WHERE resource_type=? AND resource_id=?",
|
||||
(resource_type, resource_id),
|
||||
)
|
||||
n = 0
|
||||
if text:
|
||||
for i, chunk in enumerate(chunk_text(text)[:MAX_CHUNKS_PER_RESOURCE]):
|
||||
conn.execute(
|
||||
"""INSERT INTO semantic_embeddings
|
||||
(resource_type, resource_id, chunk_id, chunk_text, embedding, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(resource_type, resource_id, i, chunk, embed_text(chunk), MODEL),
|
||||
)
|
||||
n += 1
|
||||
conn.execute(
|
||||
"""INSERT INTO semantic_index_state (resource_type, resource_id, indexed_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(resource_type, resource_id)
|
||||
DO UPDATE SET indexed_at=CURRENT_TIMESTAMP""",
|
||||
(resource_type, resource_id),
|
||||
)
|
||||
conn.commit()
|
||||
return n
|
||||
|
||||
|
||||
def _stale_resources(conn, limit: int) -> list[tuple[str, int]]:
|
||||
out: list[tuple[str, int]] = []
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.updated_at FROM pages p
|
||||
LEFT JOIN semantic_index_state s
|
||||
ON s.resource_type='page' AND s.resource_id=p.id
|
||||
WHERE (p.deleted_at IS NULL OR p.deleted_at='')
|
||||
AND COALESCE(p.search_excluded, 0)=0
|
||||
AND (s.indexed_at IS NULL OR p.updated_at > s.indexed_at)
|
||||
ORDER BY p.updated_at DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out += [("page", r["id"]) for r in rows]
|
||||
if len(out) < limit:
|
||||
rows = conn.execute(
|
||||
"""SELECT c.id, c.updated_at FROM collections c
|
||||
LEFT JOIN semantic_index_state s
|
||||
ON s.resource_type='collection' AND s.resource_id=c.id
|
||||
WHERE s.indexed_at IS NULL OR c.updated_at > s.indexed_at
|
||||
ORDER BY c.updated_at DESC LIMIT ?""",
|
||||
(limit - len(out),),
|
||||
).fetchall()
|
||||
out += [("collection", r["id"]) for r in rows]
|
||||
return out
|
||||
|
||||
|
||||
def purge_orphans() -> int:
|
||||
"""Drop vectors for deleted/excluded resources. Returns rows removed."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""DELETE FROM semantic_embeddings
|
||||
WHERE (resource_type='page' AND resource_id NOT IN (
|
||||
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
|
||||
AND COALESCE(search_excluded, 0)=0))
|
||||
OR (resource_type='collection' AND resource_id NOT IN (
|
||||
SELECT id FROM collections))"""
|
||||
)
|
||||
conn.execute(
|
||||
"""DELETE FROM semantic_index_state
|
||||
WHERE (resource_type='page' AND resource_id NOT IN (
|
||||
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
|
||||
AND COALESCE(search_excluded, 0)=0))
|
||||
OR (resource_type='collection' AND resource_id NOT IN (
|
||||
SELECT id FROM collections))"""
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def index_pending(limit: int = 50) -> dict:
|
||||
"""Index up to ``limit`` stale resources + purge orphans (scheduler job)."""
|
||||
with get_conn() as conn:
|
||||
stale = _stale_resources(conn, limit)
|
||||
indexed = 0
|
||||
for rtype, rid in stale:
|
||||
try:
|
||||
index_resource(rtype, rid)
|
||||
indexed += 1
|
||||
except Exception as exc: # never break the scheduler loop
|
||||
logger.debug("semantic index failed for %s %s: %s", rtype, rid, exc)
|
||||
purged = purge_orphans()
|
||||
return {"checked": len(stale), "indexed": indexed, "purged": purged}
|
||||
|
||||
|
||||
async def semantic_index_scheduler(interval_seconds: int = 300) -> None:
|
||||
"""Background task: incremental indexing (wired in app lifespan)."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(index_pending)
|
||||
except Exception as exc: # noqa: BLE001 — scheduler must survive
|
||||
logger.debug("semantic index scheduler: %s", exc)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
# ── vector search ──────────────────────────────────────────────────────────
|
||||
|
||||
def vector_search(query: str, *, limit: int = 20,
|
||||
resource_types: tuple[str, ...] = ("page", "collection")) -> list[dict]:
|
||||
"""Brute-force cosine scan (fine at this scale). Returns ranked chunks."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
qvec = embed_text(q)
|
||||
with get_conn() as conn:
|
||||
placeholders = ",".join("?" for _ in resource_types)
|
||||
rows = conn.execute(
|
||||
f"""SELECT resource_type, resource_id, chunk_id, chunk_text
|
||||
FROM semantic_embeddings WHERE resource_type IN ({placeholders})""",
|
||||
list(resource_types),
|
||||
).fetchall()
|
||||
scored = []
|
||||
for r in rows:
|
||||
row = conn.execute(
|
||||
"SELECT embedding FROM semantic_embeddings "
|
||||
"WHERE resource_type=? AND resource_id=? AND chunk_id=?",
|
||||
(r["resource_type"], r["resource_id"], r["chunk_id"]),
|
||||
).fetchone()
|
||||
s = cosine(qvec, row["embedding"]) if row else 0.0
|
||||
if s > 0:
|
||||
scored.append({
|
||||
"resource_type": r["resource_type"],
|
||||
"resource_id": r["resource_id"],
|
||||
"chunk_id": r["chunk_id"],
|
||||
"chunk_text": r["chunk_text"],
|
||||
"score": s,
|
||||
})
|
||||
scored.sort(key=lambda d: d["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
# ── hybrid (lexical + vector, RRF) + ACL ───────────────────────────────────
|
||||
|
||||
def _rrf_fuse(ranked_lists: list[list[tuple[str, int]]], k: int = RRF_K) -> list[tuple[str, int, float]]:
|
||||
scores: dict[tuple[str, int], float] = {}
|
||||
for ranked in ranked_lists:
|
||||
for rank, key in enumerate(ranked):
|
||||
scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1)
|
||||
fused = [(t, i, s) for (t, i), s in scores.items()]
|
||||
fused.sort(key=lambda x: x[2], reverse=True)
|
||||
return fused
|
||||
|
||||
|
||||
def hybrid_search(query: str, user: dict, *, limit: int = 20,
|
||||
workspace_id: int | None = None,
|
||||
resource_types: tuple[str, ...] = ("page", "collection")) -> tuple[list[dict], int]:
|
||||
"""Lexical + vector fusion, workspace-scoped, ACL-filtered.
|
||||
|
||||
Returns (results, total). Each result: {type, id, title, excerpt, url, score}.
|
||||
"""
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return [], 0
|
||||
limit = max(1, min(int(limit or 20), 100))
|
||||
user_id = user.get("id")
|
||||
pm = PermissionManager(user_id, bool(user.get("is_admin")))
|
||||
|
||||
# 1) lexical candidates (already workspace-membership scoped)
|
||||
from app.services import search as search_service
|
||||
lex = search_service.search(q, user_id, limit * 3)
|
||||
lex_ranked: list[tuple[str, int]] = []
|
||||
lex_by_key: dict[tuple[str, int], dict] = {}
|
||||
for item in (lex.get("pages") or []) + (lex.get("collections") or []):
|
||||
key = (item["type"], item["id"])
|
||||
if key not in lex_by_key:
|
||||
lex_by_key[key] = item
|
||||
lex_ranked.append(key)
|
||||
|
||||
# 2) vector candidates
|
||||
vec = vector_search(q, limit=limit * 3, resource_types=resource_types)
|
||||
vec_ranked = [(d["resource_type"], d["resource_id"]) for d in vec]
|
||||
|
||||
# 3) fuse
|
||||
fused = _rrf_fuse([lex_ranked, vec_ranked])
|
||||
|
||||
# 4) ACL + workspace filter, enrich
|
||||
results: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
for rtype, rid, score in fused:
|
||||
if rtype == "page":
|
||||
if not pm.can_view_page(rid):
|
||||
continue
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format, workspace_id, "
|
||||
"COALESCE(search_excluded, 0) AS excluded "
|
||||
"FROM pages WHERE id=?", (rid,)).fetchone()
|
||||
if not row or row["excluded"]:
|
||||
continue
|
||||
if workspace_id and row["workspace_id"] != workspace_id:
|
||||
continue
|
||||
excerpt = (lex_by_key.get((rtype, rid), {}).get("excerpt")
|
||||
or extract_page_text(row["content"], row["content_format"])[:160])
|
||||
results.append({"type": "page", "id": rid,
|
||||
"title": (row["title"] or "Untitled"),
|
||||
"excerpt": excerpt, "url": f"/pages/{rid}",
|
||||
"score": round(score, 5)})
|
||||
else:
|
||||
if not pm.can_view_collection(rid):
|
||||
continue
|
||||
row = conn.execute(
|
||||
"SELECT id, name, description, workspace_id FROM collections WHERE id=?",
|
||||
(rid,)).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
if workspace_id and row["workspace_id"] != workspace_id:
|
||||
continue
|
||||
excerpt = (lex_by_key.get((rtype, rid), {}).get("subtitle")
|
||||
or (row["description"] or "")[:160])
|
||||
results.append({"type": "collection", "id": rid,
|
||||
"title": (row["name"] or "Untitled"),
|
||||
"excerpt": excerpt, "url": f"/db/{rid}",
|
||||
"score": round(score, 5)})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results, len(results)
|
||||
|
||||
|
||||
# ── Ask AI ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_ask_rate(user_id: int) -> None:
|
||||
from fastapi import HTTPException
|
||||
now = time.time()
|
||||
start, count = _ask_rate.get(user_id, (now, 0))
|
||||
if now - start > _ASK_RATE_WINDOW:
|
||||
_ask_rate[user_id] = (now, 1)
|
||||
return
|
||||
if count >= _ASK_RATE_MAX:
|
||||
raise HTTPException(429, "Too many questions. Slow down.")
|
||||
_ask_rate[user_id] = (start, count + 1)
|
||||
|
||||
|
||||
def _offline_answer(question: str, chunks: list[dict]) -> str:
|
||||
"""Extractive fallback: top sentences sharing query terms + citations."""
|
||||
qterms = {t.lower() for t in _tokens(question)}
|
||||
picked: list[str] = []
|
||||
for ch in chunks[:8]:
|
||||
for sent in re.split(r"(?<=[.!?])\s+", ch["chunk_text"] or ""):
|
||||
words = {t.lower() for t in _tokens(sent)}
|
||||
if qterms & words and len(sent.strip()) > 20:
|
||||
picked.append((sent.strip(), ch))
|
||||
if len(picked) >= 4:
|
||||
break
|
||||
if len(picked) >= 4:
|
||||
break
|
||||
if not picked:
|
||||
# No lexical overlap: still cite the top vector matches.
|
||||
lines = []
|
||||
for ch in chunks[:3]:
|
||||
snippet = (ch["chunk_text"] or "")[:200].replace("\n", " ")
|
||||
lines.append(f"- {snippet} [[fdpage:{ch['resource_id']}]]"
|
||||
if ch["resource_type"] == "page" else f"- {snippet}")
|
||||
return ("Je n'ai pas trouvé de passage répondant directement, "
|
||||
"mais voici les passages les plus proches :\n" + "\n".join(lines))
|
||||
lines = []
|
||||
for sent, ch in picked:
|
||||
if ch["resource_type"] == "page":
|
||||
lines.append(f"- {sent} [[fdpage:{ch['resource_id']}]]")
|
||||
else:
|
||||
lines.append(f"- {sent}")
|
||||
return "Voici ce que j'ai trouvé dans votre workspace :\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_citations(conn, chunks: list[dict]) -> list[dict]:
|
||||
seen: list[dict] = []
|
||||
done: set[tuple[str, int]] = set()
|
||||
for ch in chunks:
|
||||
key = (ch["resource_type"], ch["resource_id"])
|
||||
if key in done:
|
||||
continue
|
||||
done.add(key)
|
||||
if ch["resource_type"] == "page":
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
|
||||
seen.append({"type": "page", "id": ch["resource_id"],
|
||||
"title": (row["title"] if row else "Deleted page") or "Untitled"})
|
||||
else:
|
||||
row = conn.execute("SELECT name FROM collections WHERE id=?",
|
||||
(ch["resource_id"],)).fetchone()
|
||||
seen.append({"type": "collection", "id": ch["resource_id"],
|
||||
"title": (row["name"] if row else "Deleted") or "Untitled"})
|
||||
return seen
|
||||
|
||||
|
||||
async def ask(question: str, user: dict, workspace_id: int | None = None) -> dict:
|
||||
"""RAG answer over the user's authorized chunks (LLM or offline fallback)."""
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
q = (question or "").strip()
|
||||
if not q:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(400, "question is required")
|
||||
_check_ask_rate(user.get("id") or 0)
|
||||
cache_key = (hashlib.sha256(q.encode()).hexdigest(), workspace_id, user.get("id"))
|
||||
now = time.time()
|
||||
hit = _ask_cache.get(cache_key)
|
||||
if hit and hit[0] > now:
|
||||
out = dict(hit[1])
|
||||
out["cached"] = True
|
||||
return out
|
||||
|
||||
pm = PermissionManager(user.get("id"), bool(user.get("is_admin")))
|
||||
vec = vector_search(q, limit=24)
|
||||
allowed = []
|
||||
with get_conn() as conn:
|
||||
for ch in vec:
|
||||
if ch["resource_type"] == "page":
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id, COALESCE(search_excluded, 0) AS excluded "
|
||||
"FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
|
||||
if not row or row["excluded"] or not pm.can_view_page(ch["resource_id"]):
|
||||
continue
|
||||
if workspace_id and row["workspace_id"] != workspace_id:
|
||||
continue
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?",
|
||||
(ch["resource_id"],)).fetchone()
|
||||
if not row or not pm.can_view_collection(ch["resource_id"]):
|
||||
continue
|
||||
if workspace_id and row["workspace_id"] != workspace_id:
|
||||
continue
|
||||
allowed.append(ch)
|
||||
if len(allowed) >= 8:
|
||||
break
|
||||
|
||||
answer = ""
|
||||
offline = True
|
||||
if allowed:
|
||||
try:
|
||||
from app.services.llm_client import LLMClient
|
||||
llm = LLMClient()
|
||||
if await llm.is_available():
|
||||
ctx = "\n\n".join(
|
||||
f"[doc {i+1} page_id={c['resource_id']}]\n{c['chunk_text'][:1500]}"
|
||||
for i, c in enumerate(allowed)
|
||||
)
|
||||
resp = await llm.complete([
|
||||
{"role": "system",
|
||||
"content": "Réponds en français en citant les sources avec "
|
||||
"[[fdpage:ID]] (ID = page_id indiqué). Concis."},
|
||||
{"role": "user", "content": f"Question : {q}\n\nContexte :\n{ctx}"},
|
||||
])
|
||||
answer = (resp.text or "").strip()
|
||||
offline = False
|
||||
except Exception as exc: # noqa: BLE001 — fall back to extractive
|
||||
logger.debug("ask LLM failed, offline fallback: %s", exc)
|
||||
if not answer:
|
||||
answer = ("Aucun contenu accessible ne correspond à votre question."
|
||||
if not allowed else _offline_answer(q, allowed))
|
||||
|
||||
with get_conn() as conn:
|
||||
citations = _resolve_citations(conn, allowed[:8])
|
||||
out = {"answer_markdown": answer, "citations": citations,
|
||||
"offline": offline, "cached": False}
|
||||
_ask_cache[cache_key] = (now + _ASK_CACHE_TTL, out)
|
||||
return out
|
||||
|
||||
|
||||
def reset_state() -> None:
|
||||
"""Test helper: clear ask cache + rate limiter."""
|
||||
_ask_cache.clear()
|
||||
_ask_rate.clear()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""FlowDeck — TOTP 2FA + backup codes (v7.2.0).
|
||||
|
||||
Secrets are Fernet-encrypted at rest (same construction as SSO secrets).
|
||||
Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived
|
||||
signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a
|
||||
session. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
BACKUP_CODE_COUNT = 10
|
||||
|
||||
|
||||
def _fernet():
|
||||
import base64
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def is_enabled(user_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?",
|
||||
(user_id,)).fetchone()
|
||||
if not row or not row["totp_secret_enc"]:
|
||||
return False
|
||||
try:
|
||||
return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode())
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def setup_secret(user_id: int) -> dict:
|
||||
"""Create a new TOTP secret (not yet active until verified)."""
|
||||
import pyotp
|
||||
secret = pyotp.random_base32()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
label = (row["email"] or row["login"]) if row else f"user{user_id}"
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck")
|
||||
return {"secret": secret, "otpauth_url": uri}
|
||||
|
||||
|
||||
def activate_secret(user_id: int, secret: str, code: str) -> list[str]:
|
||||
"""Verify ``code`` against ``secret``; on success store + return backup codes."""
|
||||
import pyotp
|
||||
if not pyotp.TOTP(secret).verify(code, valid_window=1):
|
||||
raise ValueError("invalid code")
|
||||
codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)]
|
||||
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?",
|
||||
(_fernet().encrypt(secret.encode()).decode(),
|
||||
json.dumps(hashes), user_id))
|
||||
conn.commit()
|
||||
return codes
|
||||
|
||||
|
||||
def verify_code(user_id: int, code: str) -> bool:
|
||||
"""Check a TOTP code or consume a backup code."""
|
||||
code = (code or "").strip().replace(" ", "")
|
||||
if not code:
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?",
|
||||
(user_id,)).fetchone()
|
||||
if not row or not row["totp_secret_enc"]:
|
||||
return False
|
||||
try:
|
||||
secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode()
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
import pyotp
|
||||
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
|
||||
return True
|
||||
# backup codes (single use)
|
||||
try:
|
||||
hashes = json.loads(row["totp_backup_hashes"] or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
hashes = []
|
||||
digest = hashlib.sha256(code.encode()).hexdigest()
|
||||
if digest in hashes:
|
||||
hashes.remove(digest)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?",
|
||||
(json.dumps(hashes), user_id))
|
||||
conn.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def disable(user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'"
|
||||
" WHERE id=?", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def remaining_backup_codes(user_id: int) -> int:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?",
|
||||
(user_id,)).fetchone()
|
||||
try:
|
||||
return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return 0
|
||||
|
||||
|
||||
# ── pending 2FA challenge (signed, 5 min) ──────────────────────────────────
|
||||
|
||||
def mint_pending(user_id: int) -> str:
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
|
||||
return ser.dumps({"user_id": user_id})
|
||||
|
||||
|
||||
def redeem_pending(token: str, max_age: int = 300) -> int | None:
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.config import settings
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
|
||||
try:
|
||||
payload = ser.loads(token, max_age=max_age)
|
||||
return int(payload.get("user_id", 0)) or None
|
||||
except (BadSignature, SignatureExpired, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,415 @@
|
||||
"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0).
|
||||
|
||||
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
|
||||
|
||||
- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux
|
||||
non-membres (404, comme une collection restricted).
|
||||
- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut).
|
||||
- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ;
|
||||
``guest_shares`` (``/g/<token>``) ; ``page_views`` (compteurs journaliers).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
VERIFICATION_DAYS_DEFAULT = 90
|
||||
# Roles, strongest first. Mirrors collection roles.
|
||||
TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer")
|
||||
_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))}
|
||||
|
||||
|
||||
def _utcnow() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
|
||||
def _iso(dt: datetime.datetime) -> str:
|
||||
return dt.replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None:
|
||||
"""Explicit role, else workspace role, else ``None`` when unreachable."""
|
||||
if not user_id:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?",
|
||||
(teamspace_id,)).fetchone()
|
||||
if not ts:
|
||||
return None
|
||||
row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
|
||||
(teamspace_id, user_id)).fetchone()
|
||||
if row:
|
||||
return row["role"]
|
||||
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if admin and admin["is_admin"]:
|
||||
return "owner"
|
||||
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
|
||||
(ts["workspace_id"],)).fetchone()
|
||||
if owner and owner["owner_id"] == user_id:
|
||||
return "owner"
|
||||
if not ts["private"]:
|
||||
# a public teamspace is still workspace-scoped: no workspace
|
||||
# membership means no access (otherwise any logged-in account on
|
||||
# the instance could read every public teamspace).
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ts["workspace_id"], user_id)).fetchone()
|
||||
return member["role"] if member else None
|
||||
return None
|
||||
|
||||
|
||||
def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool:
|
||||
return get_teamspace_role(user_id, teamspace_id) is not None
|
||||
|
||||
|
||||
def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool:
|
||||
role = get_teamspace_role(user_id, teamspace_id)
|
||||
return role in ("owner", "editor")
|
||||
|
||||
|
||||
def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]:
|
||||
"""Teamspaces the user can see (private ones filtered out).
|
||||
|
||||
``workspace_id=None`` lists across every workspace (global sidebar).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
if workspace_id is not None:
|
||||
ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(workspace_id, user_id)).fetchone()
|
||||
if not ws_member:
|
||||
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
|
||||
(workspace_id,)).fetchone()
|
||||
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not owner or owner["owner_id"] != user_id:
|
||||
if not (admin and admin["is_admin"]):
|
||||
return []
|
||||
rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name",
|
||||
(workspace_id,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
role = get_teamspace_role(user_id, r["id"])
|
||||
if role is None:
|
||||
continue
|
||||
counts = conn.execute(
|
||||
"""SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?)
|
||||
+ (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""",
|
||||
(r["id"], r["id"])).fetchone()
|
||||
item = dict(r)
|
||||
item["role"] = role
|
||||
item["workspace_name"] = _workspace_name(conn, r["workspace_id"])
|
||||
item["item_count"] = counts["n"]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _workspace_name(conn, workspace_id: int) -> str:
|
||||
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
return row["name"] if row else ""
|
||||
|
||||
|
||||
def teamspace_pages(teamspace_id: int) -> list[dict]:
|
||||
"""Non-deleted pages belonging to a teamspace (title/URL order)."""
|
||||
with get_conn() as conn:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"""SELECT id, title, workspace_id, teamspace_id
|
||||
FROM pages WHERE teamspace_id=? AND deleted_at IS NULL
|
||||
ORDER BY title""", (teamspace_id,)).fetchall()]
|
||||
|
||||
|
||||
def teamspace_collections(teamspace_id: int) -> list[dict]:
|
||||
"""Databases belonging to a teamspace."""
|
||||
with get_conn() as conn:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"""SELECT id, name, icon FROM collections
|
||||
WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()]
|
||||
|
||||
|
||||
def create_teamspace(workspace_id: int, name: str, user_id: int,
|
||||
description: str = "", private: bool = False) -> int:
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO teamspaces (workspace_id, name, description, private, created_by)"
|
||||
" VALUES (?,?,?,?,?)",
|
||||
(workspace_id, name.strip(), description[:400], 1 if private else 0, user_id))
|
||||
except Exception as exc: # UNIQUE(workspace_id, name)
|
||||
if "UNIQUE" in str(exc):
|
||||
raise ValueError("A teamspace with this name already exists") from None
|
||||
raise
|
||||
conn.commit()
|
||||
# the creator is owner
|
||||
conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)"
|
||||
" VALUES (?,?,'owner')", (cur.lastrowid, user_id))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def teamspace_member_ids(teamspace_id: int) -> list[int]:
|
||||
with get_conn() as conn:
|
||||
return [r["user_id"] for r in conn.execute(
|
||||
"SELECT user_id FROM teamspace_members WHERE teamspace_id=?",
|
||||
(teamspace_id,)).fetchall()]
|
||||
|
||||
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
def is_expired(row) -> bool:
|
||||
if not row or not row["expires_at"]:
|
||||
return False
|
||||
try:
|
||||
return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"])
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT,
|
||||
note: str = "") -> dict:
|
||||
days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365))
|
||||
expires = _iso(_utcnow() + datetime.timedelta(days=days))
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO page_verifications (page_id, verified_by, note, expires_at)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(page_id) DO UPDATE SET
|
||||
verified_by=excluded.verified_by, note=excluded.note,
|
||||
verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""",
|
||||
(page_id, user_id, note[:400], expires))
|
||||
conn.commit()
|
||||
return verification(page_id)
|
||||
|
||||
|
||||
def verification(page_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT v.*, u.full_name, u.login FROM page_verifications v
|
||||
LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["expired"] = is_expired(row)
|
||||
d["active"] = not d["expired"]
|
||||
return d
|
||||
|
||||
|
||||
def unverify_page(page_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,))
|
||||
conn.commit()
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
def expiring_verifications(days: int = 7) -> list[dict]:
|
||||
"""Verifications expiring within ``days`` (drives the owner notification).
|
||||
|
||||
``pages`` has no owner column in FlowDeck, so the reminder targets the user
|
||||
who performed the verification.
|
||||
"""
|
||||
horizon = _iso(_utcnow() + datetime.timedelta(days=days))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""",
|
||||
(horizon,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_following(page_id: int, user_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
|
||||
(page_id, user_id)).fetchone())
|
||||
|
||||
|
||||
def toggle_follow(page_id: int, user_id: int) -> bool:
|
||||
"""Returns the new state (True = now following)."""
|
||||
with get_conn() as conn:
|
||||
if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
|
||||
(page_id, user_id)).fetchone():
|
||||
conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?",
|
||||
(page_id, user_id))
|
||||
conn.commit()
|
||||
return False
|
||||
conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
|
||||
(page_id, user_id))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def ensure_follow(page_id: int, user_id: int, conn=None) -> bool:
|
||||
"""Follow unless already following. Returns True when newly followed."""
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
def _run(c):
|
||||
if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
|
||||
(page_id, user_id)).fetchone():
|
||||
return False
|
||||
c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
|
||||
(page_id, user_id))
|
||||
return True
|
||||
|
||||
if conn is not None:
|
||||
added = _run(conn)
|
||||
conn.commit()
|
||||
return added
|
||||
with get_conn() as _c:
|
||||
added = _run(_c)
|
||||
_c.commit()
|
||||
return added
|
||||
|
||||
|
||||
def followers(page_id: int) -> list[int]:
|
||||
with get_conn() as conn:
|
||||
return [r["user_id"] for r in conn.execute(
|
||||
"SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()]
|
||||
|
||||
|
||||
# Rate-limit window for ``page.updated`` notifications: the block editor
|
||||
# autosaves every ~1.5 s; without it followers would be spammed per keystroke.
|
||||
_UPDATE_NOTIF_WINDOW_MIN = 10
|
||||
|
||||
|
||||
def notify_followers_of_page_update(page_id: int, actor_id: int | None,
|
||||
title: str = "") -> int:
|
||||
"""Create a ``page.updated`` notification for every follower (except the
|
||||
actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes.
|
||||
Returns the number of notifications created."""
|
||||
if not page_id:
|
||||
return 0
|
||||
from app.services.notifications import create_notification
|
||||
with get_conn() as conn:
|
||||
followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()
|
||||
if not followed:
|
||||
return 0
|
||||
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
title = (title or (page["title"] if page else "") or "Untitled")
|
||||
created = 0
|
||||
for f in followed:
|
||||
uid = f["user_id"]
|
||||
if uid == actor_id:
|
||||
continue
|
||||
recent = conn.execute(
|
||||
"""SELECT 1 FROM notifications
|
||||
WHERE user_id=? AND resource_type='page' AND resource_id=?
|
||||
AND ntype='page.updated'
|
||||
AND created_at >= datetime('now', ?)""",
|
||||
(uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone()
|
||||
if recent:
|
||||
continue
|
||||
create_notification(
|
||||
uid, actor_id, "page.updated",
|
||||
title=f'"{title}" was updated',
|
||||
message=f'Page "{title}" has been modified',
|
||||
resource_type="page", resource_id=page_id,
|
||||
url=f"/pages/{page_id}", conn=conn, commit=False,
|
||||
)
|
||||
created += 1
|
||||
conn.commit()
|
||||
return created
|
||||
|
||||
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict:
|
||||
"""Add or remove ``emoji``; returns the aggregated counts for the comment."""
|
||||
emoji = (emoji or "").strip()[:16]
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone():
|
||||
raise LookupError("Comment not found")
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?",
|
||||
(comment_id, user_id, emoji)).fetchone()
|
||||
if existing:
|
||||
conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],))
|
||||
conn.commit()
|
||||
else:
|
||||
conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)"
|
||||
" VALUES (?,?,?)", (comment_id, user_id, emoji))
|
||||
conn.commit()
|
||||
return reactions(comment_id)
|
||||
|
||||
|
||||
def reactions(comment_id: int) -> dict[str, dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT emoji, COUNT(*) AS n,
|
||||
GROUP_CONCAT(user_id) AS users
|
||||
FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""",
|
||||
(comment_id,)).fetchall()
|
||||
return {r["emoji"]: {"count": r["n"],
|
||||
"users": [int(u) for u in (r["users"] or "").split(",") if u]}
|
||||
for r in rows}
|
||||
|
||||
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
def create_guest_share(page_id: int, email: str, role: str, created_by: int,
|
||||
days: int | None = 30) -> dict:
|
||||
if role not in ("viewer", "commenter"):
|
||||
raise ValueError("role must be viewer or commenter")
|
||||
token = f"g_{secrets.token_urlsafe(24)}"
|
||||
expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(page_id, email[:200], token, role, created_by, expires))
|
||||
conn.commit()
|
||||
return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?",
|
||||
(cur.lastrowid,)).fetchone())
|
||||
|
||||
|
||||
def resolve_guest_share(token: str) -> dict | None:
|
||||
"""Active share for ``token``, or ``None`` (unknown / revoked / expired)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone()
|
||||
if not row or row["revoked"]:
|
||||
return None
|
||||
if row["expires_at"]:
|
||||
try:
|
||||
if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]):
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
def record_view(page_id: int, day: str | None = None) -> int:
|
||||
day = day or _utcnow().strftime("%Y-%m-%d")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO page_views (page_id, day, views) VALUES (?,?,1)
|
||||
ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""",
|
||||
(page_id, day))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?",
|
||||
(page_id, day)).fetchone()
|
||||
return row["views"]
|
||||
|
||||
|
||||
def view_stats(page_id: int, days: int = 30) -> dict:
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day",
|
||||
(page_id, since)).fetchall()
|
||||
series = {r["day"]: r["views"] for r in rows}
|
||||
# fill the gap so charts have no holes
|
||||
out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1)
|
||||
for _ in range(days):
|
||||
key = cursor.strftime("%Y-%m-%d")
|
||||
out.append({"day": key, "views": series.get(key, 0)})
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return {"page_id": page_id, "days": days, "total": sum(series.values()),
|
||||
"series": out}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress.
|
||||
|
||||
Server-side rendering so HTML/PDF export embeds real content (the editor
|
||||
already has Prism + KaTeX client-side). Design §2 of
|
||||
``docs/V73_Wiki_Teamspaces_Polish.md``.
|
||||
|
||||
- ``mermaid`` : SVG via ``mmdc`` when installed, else a ``<pre>`` fallback
|
||||
that mermaid.js can still render in the browser.
|
||||
- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
|
||||
falls back to readable plaintext.
|
||||
- ``progress`` : ``{"rollup_ref": {collection_id, property_id}}`` → percent
|
||||
bar computed with :class:`RollupEngine`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# ── mermaid ────────────────────────────────────────────────────────────────
|
||||
|
||||
def mmdc_available() -> bool:
|
||||
return shutil.which("mmdc") is not None
|
||||
|
||||
|
||||
def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
|
||||
"""Render mermaid source to an inline SVG, or ``None`` if unavailable.
|
||||
|
||||
Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
|
||||
degrade to the code fallback.
|
||||
"""
|
||||
source = (source or "").strip()
|
||||
if not source or not mmdc_available():
|
||||
return None
|
||||
with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
|
||||
src = Path(tmp) / "diagram.mmd"
|
||||
out = Path(tmp) / "diagram.svg"
|
||||
src.write_text(source, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
|
||||
capture_output=True, timeout=timeout, check=True)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
if not out.exists():
|
||||
return None
|
||||
svg = out.read_text(encoding="utf-8", errors="replace")
|
||||
# strip the XML prolog / doctype so the SVG can be inlined
|
||||
svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
|
||||
return svg or None
|
||||
|
||||
|
||||
def render_mermaid(source: str) -> str:
|
||||
"""Inline SVG when possible, else a mermaid-renderable code block."""
|
||||
svg = mermaid_to_svg(source)
|
||||
if svg:
|
||||
return f'<figure class="mermaid-figure">{svg}</figure>'
|
||||
return (f'<pre class="mermaid"><code class="language-mermaid">'
|
||||
f'{html.escape(source or "")}</code></pre>')
|
||||
|
||||
|
||||
# ── equations ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Only characters that are meaningful inside a math expression are kept. Note
|
||||
# that `<`, `>` and `\` are NOT allowed: they would let the source close the
|
||||
# KaTeX delimiter early or inject markup into the exported HTML.
|
||||
_EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]")
|
||||
|
||||
|
||||
def sanitize_equation(source: str) -> str:
|
||||
return _EQUATION_ALLOWED.sub("", source or "").strip()
|
||||
|
||||
|
||||
def render_equation(source: str) -> str:
|
||||
expr = sanitize_equation(source)
|
||||
if not expr:
|
||||
return ""
|
||||
return (f'<span class="fd-equation" data-equation="{html.escape(expr)}">'
|
||||
f'\\({html.escape(expr)}\\)</span>')
|
||||
|
||||
|
||||
# ── progress ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows_values(collection_id: int) -> list[dict]:
|
||||
import json as _json
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
try:
|
||||
out.append(_json.loads(r["property_values_json"] or "{}"))
|
||||
except ValueError:
|
||||
out.append({})
|
||||
return out
|
||||
|
||||
|
||||
_DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed")
|
||||
|
||||
|
||||
def _is_done(value) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in _DONE_TRUE
|
||||
return False
|
||||
|
||||
|
||||
def progress_value(rollup_ref: dict | None) -> dict:
|
||||
"""Resolve ``{collection_id, done_property_id | property_id, func}`` to a
|
||||
0..100 percent.
|
||||
|
||||
Aggregates directly over ``collection_pages.property_values_json`` (the
|
||||
relation-based :class:`RollupEngine` needs a relation chain + a source
|
||||
page, which a page-embedded progress bar does not have).
|
||||
"""
|
||||
from app.db import get_conn
|
||||
if not rollup_ref or not rollup_ref.get("collection_id"):
|
||||
return {"percent": None, "done": None, "total": None}
|
||||
cid = rollup_ref["collection_id"]
|
||||
values = _rows_values(cid)
|
||||
total = len(values)
|
||||
|
||||
done_pid = rollup_ref.get("done_property_id")
|
||||
if done_pid:
|
||||
key = str(done_pid)
|
||||
done = sum(1 for vals in values if _is_done(vals.get(key)))
|
||||
if total:
|
||||
return {"percent": round(done * 100 / total, 1), "done": done, "total": total}
|
||||
return {"percent": 0.0, "done": 0, "total": 0}
|
||||
|
||||
pid = rollup_ref.get("property_id")
|
||||
if not pid or not total:
|
||||
return {"percent": None, "done": None, "total": total}
|
||||
key = str(pid)
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND"
|
||||
" collection_id=?", (pid, cid)).fetchone()
|
||||
if not prop:
|
||||
return {"percent": None, "done": None, "total": total}
|
||||
func = (rollup_ref.get("func") or "count").lower()
|
||||
if prop["prop_type"] in ("checkbox", "status", "select"):
|
||||
done = sum(1 for vals in values if _is_done(vals.get(key)))
|
||||
elif prop["prop_type"] in ("number", "formula", "rollup"):
|
||||
nums = []
|
||||
for vals in values:
|
||||
v = vals.get(key)
|
||||
try:
|
||||
nums.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not nums:
|
||||
return {"percent": None, "done": None, "total": total}
|
||||
done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs),
|
||||
"max": max, "min": min}.get(func, len)(nums)
|
||||
else:
|
||||
done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {}))
|
||||
if not total:
|
||||
return {"percent": 0.0, "done": done, "total": 0}
|
||||
return {"percent": round(min(float(done) * 100 / total, 100), 1),
|
||||
"done": done, "total": total}
|
||||
|
||||
|
||||
def render_progress(block: dict) -> str:
|
||||
stats = progress_value(block.get("rollup_ref") or block)
|
||||
pct = stats["percent"]
|
||||
label = block.get("label") or "Progress"
|
||||
if pct is None:
|
||||
return (f'<div class="fd-progress" data-percent=""><div class="fd-progress-label">'
|
||||
f'{html.escape(label)}: —</div></div>')
|
||||
detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else ""
|
||||
return (f'<div class="fd-progress" data-percent="{pct}">'
|
||||
f'<div class="fd-progress-label">{html.escape(label)}: {pct}%'
|
||||
f'{(" (" + html.escape(detail) + ")") if detail else ""}</div>'
|
||||
f'<div class="fd-progress-bar"><div class="fd-progress-fill"'
|
||||
f' style="width:{min(pct, 100)}%"></div></div></div>')
|
||||
|
||||
|
||||
def render_block(block: dict) -> str:
|
||||
"""Dispatch for the three v7.3 block types (used by export + preview API)."""
|
||||
t = block.get("type")
|
||||
if t == "mermaid":
|
||||
return render_mermaid(block.get("content") or block.get("source") or "")
|
||||
if t == "equation_inline":
|
||||
return render_equation(block.get("content") or "")
|
||||
if t == "progress":
|
||||
return render_progress(block)
|
||||
return ""
|
||||
@@ -0,0 +1,234 @@
|
||||
"""FlowDeck — Workers lite (v7.0.0).
|
||||
|
||||
Custom Python snippets run on FlowDeck infrastructure: manual, on a cron
|
||||
schedule, or shared across the team (fork). Parité Notion Workers (07/2026),
|
||||
sans facturation : un budget journalier secondes/workspace fait office de
|
||||
« credits dashboard ».
|
||||
|
||||
Sandbox (documenté, best-effort single-process) :
|
||||
- AST blacklist : ``import os/sys/subprocess/socket``, ``open()``,
|
||||
``exec/eval/compile``, attributs dunder.
|
||||
- Pas de réseau, pas de FS ; builtins restreints (pas de ``__import__``).
|
||||
- Timeout 30 s (thread + join), budget journalier ``daily_budget_s``.
|
||||
- Seules API exposées : ``log()``, ``ctx`` (dict), ``result`` (dict out).
|
||||
|
||||
Voir ``docs/V70_Automations_Workers.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RUN_TIMEOUT_S = 30
|
||||
MAX_CODE_CHARS = 20_000
|
||||
MAX_LOG_CHARS = 10_000
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,60}$")
|
||||
|
||||
_FORBIDDEN_IMPORTS = {"os", "sys", "subprocess", "socket", "shutil",
|
||||
"pathlib", "io", "asyncio", "threading", "multiprocessing"}
|
||||
_FORBIDDEN_CALLS = {"open", "exec", "eval", "compile", "__import__"}
|
||||
|
||||
|
||||
class WorkerRejected(ValueError):
|
||||
"""Raised when worker code violates the sandbox policy."""
|
||||
|
||||
|
||||
def validate_code(code: str) -> None:
|
||||
"""AST lint of worker code. Raises WorkerRejected on violation."""
|
||||
code = code or ""
|
||||
if len(code) > MAX_CODE_CHARS:
|
||||
raise WorkerRejected(f"code too long ({len(code)} > {MAX_CODE_CHARS})")
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as exc:
|
||||
raise WorkerRejected(f"syntax error: {exc}") from None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
names = [a.name.split(".")[0] for a in node.names]
|
||||
if getattr(node, "module", None):
|
||||
names.append(str(node.module).split(".")[0])
|
||||
for name in names:
|
||||
if name in _FORBIDDEN_IMPORTS:
|
||||
raise WorkerRejected(f"import forbidden: {name}")
|
||||
elif isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in _FORBIDDEN_CALLS:
|
||||
raise WorkerRejected(f"call forbidden: {func.id}()")
|
||||
elif isinstance(node, ast.Attribute):
|
||||
if isinstance(node.attr, str) and node.attr.startswith("__"):
|
||||
raise WorkerRejected(f"dunder access forbidden: {node.attr}")
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
import unicodedata
|
||||
slug = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
return re.sub(r"[-\s]+", "-", slug).strip("-") or "worker"
|
||||
|
||||
|
||||
def unique_slug(base: str, ignore_id: int | None = None) -> str:
|
||||
slug, i = _slugify(base)[:60] or "worker", 1
|
||||
with get_conn() as conn:
|
||||
while conn.execute(
|
||||
"SELECT id FROM workers WHERE slug=? AND id != COALESCE(?, -1)",
|
||||
(slug, ignore_id)).fetchone():
|
||||
i += 1
|
||||
slug = f"{_slugify(base)[:55]}-{i}"
|
||||
return slug
|
||||
|
||||
|
||||
_SAFE_BUILTINS = {
|
||||
"abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
|
||||
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
|
||||
"frozenset": frozenset, "int": int, "len": len, "list": list, "map": map,
|
||||
"max": max, "min": min, "range": range, "reversed": reversed, "round": round,
|
||||
"set": set, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
|
||||
"zip": zip, "print": print, "isinstance": isinstance, "type": type,
|
||||
}
|
||||
|
||||
|
||||
def _exec_code(code: str, ctx: dict) -> tuple[dict, str]:
|
||||
"""Run validated code in a thread. Returns (result_dict, logs)."""
|
||||
logs: list[str] = []
|
||||
|
||||
def _log(*args) -> None:
|
||||
logs.append(" ".join(str(a) for a in args))
|
||||
|
||||
namespace = {"__builtins__": dict(_SAFE_BUILTINS),
|
||||
"log": _log, "ctx": dict(ctx or {}), "result": {}}
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
exec(compile(code, "<worker>", "exec"), namespace) # noqa: S102 — sandboxed
|
||||
printed = buf.getvalue()
|
||||
if printed:
|
||||
logs.append(printed)
|
||||
result = namespace.get("result")
|
||||
return result if isinstance(result, dict) else {}, "\n".join(logs)[:MAX_LOG_CHARS]
|
||||
|
||||
|
||||
def daily_usage_s(workspace_id: int | None) -> float:
|
||||
"""CPU seconds consumed today (UTC) by a workspace's workers."""
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT COALESCE(SUM(wr.duration_ms), 0) FROM worker_runs wr
|
||||
JOIN workers w ON w.id = wr.worker_id
|
||||
WHERE date(wr.created_at) = date(?)
|
||||
AND COALESCE(w.workspace_id, -1) = COALESCE(?, -1)""",
|
||||
(day, workspace_id)).fetchone()
|
||||
return (row[0] or 0) / 1000.0
|
||||
|
||||
|
||||
def _save_run(worker_id: int, status: str, logs: str, duration_ms: int) -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO worker_runs (worker_id, status, logs, duration_ms)"
|
||||
" VALUES (?,?,?,?)", (worker_id, status, logs[:MAX_LOG_CHARS], duration_ms))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def run_worker(worker_id: int, ctx: dict | None = None) -> dict:
|
||||
"""Execute a worker synchronously (used by the router + cron loop).
|
||||
|
||||
Returns {status, run_id, duration_ms}. Never raises for user-code errors
|
||||
(they become ``error`` runs); raises only when the worker is missing or
|
||||
over budget (caller maps to 404/429).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
worker = dict(row)
|
||||
validate_code(worker.get("code_py") or "")
|
||||
used = daily_usage_s(worker.get("workspace_id"))
|
||||
if used >= (worker.get("daily_budget_s") or 60):
|
||||
_save_run(worker_id, "over_budget", f"daily budget exceeded ({used:.1f}s used)", 0)
|
||||
raise HTTPException(429, "Worker daily budget exceeded")
|
||||
|
||||
outcome: dict = {}
|
||||
|
||||
def _target() -> None:
|
||||
try:
|
||||
result, logs = _exec_code(worker.get("code_py") or "", ctx or {})
|
||||
outcome["result"] = result
|
||||
outcome["logs"] = logs
|
||||
except Exception as exc: # noqa: BLE001 — user code, recorded
|
||||
outcome["error"] = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
import threading
|
||||
started = time.time()
|
||||
thread = threading.Thread(target=_target, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=RUN_TIMEOUT_S)
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
if thread.is_alive():
|
||||
run_id = _save_run(worker_id, "timeout",
|
||||
f"exceeded {RUN_TIMEOUT_S}s timeout", duration_ms)
|
||||
return {"status": "timeout", "run_id": run_id, "duration_ms": duration_ms}
|
||||
if "error" in outcome:
|
||||
run_id = _save_run(worker_id, "error", outcome["error"], duration_ms)
|
||||
return {"status": "error", "run_id": run_id,
|
||||
"duration_ms": duration_ms, "error": outcome["error"]}
|
||||
run_id = _save_run(worker_id, "ok", outcome.get("logs", ""), duration_ms)
|
||||
return {"status": "ok", "run_id": run_id, "duration_ms": duration_ms,
|
||||
"result": outcome.get("result", {})}
|
||||
|
||||
|
||||
async def run_due_workers() -> int:
|
||||
"""Fire workers whose ``schedule_cron`` is due (called from the 60s loop)."""
|
||||
from app.services.automations import cron_due
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workers WHERE schedule_cron IS NOT NULL AND schedule_cron != ''"
|
||||
).fetchall()
|
||||
fired = 0
|
||||
for row in rows:
|
||||
worker = dict(row)
|
||||
with get_conn() as conn:
|
||||
last = conn.execute(
|
||||
"SELECT MAX(created_at) FROM worker_runs WHERE worker_id=?",
|
||||
(worker["id"],)).fetchone()[0]
|
||||
try:
|
||||
if cron_due(worker["schedule_cron"] or "", last):
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, run_worker, worker["id"], {})
|
||||
fired += 1
|
||||
except Exception as exc: # noqa: BLE001 — one worker must not kill the loop
|
||||
logger.debug("worker %s cron failed: %s", worker["id"], exc)
|
||||
return fired
|
||||
|
||||
|
||||
def fork_worker(worker_id: int, user_id: int) -> dict:
|
||||
"""Duplicate a shared worker for another user (Notion-style sharing)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(404, "Worker not found")
|
||||
src = dict(row)
|
||||
if not src.get("shared") and src.get("created_by") != user_id:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(403, "Worker is not shared")
|
||||
slug = unique_slug(f"{src['slug']}-fork")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
|
||||
shared, daily_budget_s, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(slug, src["workspace_id"], f"{src['name']} (fork)", src["code_py"],
|
||||
"", 0, src["daily_budget_s"], user_id))
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
return {"id": new_id, "slug": slug, "status": "forked", "from": worker_id}
|
||||
@@ -2393,7 +2393,7 @@ applyAIBlocks(text){
|
||||
if(this.saving){ if(cb) cb(); return; }
|
||||
this.sync();
|
||||
this.saving=true;
|
||||
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
|
||||
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name','url','title','description','image','site_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
|
||||
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
|
||||
const queueOffline=()=>{
|
||||
if(!window.FlowOffline){this.saving=false;return;}
|
||||
|
||||
@@ -555,7 +555,7 @@
|
||||
var payload = {prompt: message};
|
||||
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
|
||||
return fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
return resp.json();
|
||||
@@ -701,7 +701,7 @@
|
||||
if(self.llmModel) payload.model = self.llmModel;
|
||||
|
||||
fetch('/api/agent/generate', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
|
||||
if(d && d.ok && d.text){
|
||||
@@ -824,7 +824,7 @@
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
@@ -988,7 +988,7 @@
|
||||
var body = {title:'Nouvelle conversation'};
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -1005,7 +1005,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
self._ensuring = fetch('/api/agent/conversations', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
|
||||
self.conversations.unshift(conv); self.currentConv = conv;
|
||||
@@ -1027,7 +1027,7 @@
|
||||
if(self.llmProvider) body.provider = self.llmProvider;
|
||||
if(self.llmModel) body.model = self.llmModel;
|
||||
fetch('/api/agent/conversations/'+self.currentConv.id, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
|
||||
}).catch(function(){});
|
||||
},
|
||||
|
||||
@@ -1832,7 +1832,7 @@
|
||||
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
|
||||
fetch('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if(d && d.status === 'recorded'){ m.fb = rating; }
|
||||
@@ -1930,7 +1930,7 @@
|
||||
|
||||
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(resp){
|
||||
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
|
||||
|
||||
+49
-9
@@ -404,6 +404,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teamspaces -->
|
||||
<div class="sidebar-section" x-show="isSectionVisible('teamspaces')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="sidebar-section-header" @click="toggleSection('teamspaces')" @contextmenu.prevent="openSectionMenu($event, 'teamspaces')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.teamspaces }">▶</span>
|
||||
<span class="section-icon">{{ fd_icon("home",14) }}</span>
|
||||
<span class="section-label">Teamspaces</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="loadTeamspaces()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'teamspaces')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.teamspaces" x-transition>
|
||||
<ul class="sidebar-items" data-section="teamspaces">
|
||||
<template x-for="t in teamspaces" :key="t.id">
|
||||
<li class="sidebar-item" @click="openTeamspace(t.id)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span>
|
||||
<span class="page-name" x-text="t.name"></span>
|
||||
<span class="page-name text-dim" style="font-size:11px;" x-text="t.item_count + (t.private ? ' · 🔒' : '')"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!teamspaces.length"><span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span><span class="page-name text-dim">No teamspaces yet</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared -->
|
||||
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
|
||||
@@ -503,9 +530,10 @@
|
||||
</div>
|
||||
<div class="scp-list">
|
||||
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
||||
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
|
||||
recents:{visible:true,order:2},favorites:{visible:true,order:3},
|
||||
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
|
||||
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
|
||||
meetings:{visible:true,order:2},recents:{visible:true,order:3},
|
||||
favorites:{visible:true,order:4},agents:{visible:true,order:5},
|
||||
shared:{visible:true,order:6},published:{visible:true,order:7}
|
||||
})" :key="key">
|
||||
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
||||
<div class="scp-item-left">
|
||||
@@ -1098,6 +1126,7 @@
|
||||
window.appState = this;
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
this.loadTeamspaces();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
this.startClipAutoRefresh();
|
||||
@@ -1157,6 +1186,17 @@
|
||||
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
|
||||
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
|
||||
},
|
||||
teamspaces: [],
|
||||
loadTeamspaces() {
|
||||
var self = this;
|
||||
fetch('/api/v2/wiki/teamspaces').then(function(r){ return r.json(); }).then(function(d){
|
||||
self.teamspaces = d.teamspaces || [];
|
||||
}).catch(function(){});
|
||||
},
|
||||
openTeamspace(id) {
|
||||
var target = '/wiki/teamspaces/' + id;
|
||||
if (window.fdNavigate) window.fdNavigate(target); else window.location = target;
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
sidebarPeek: false,
|
||||
mobileSidebarOpen: false,
|
||||
@@ -1310,7 +1350,7 @@
|
||||
|
||||
// ── Sections collapsible ──
|
||||
sectionsOpen: (function() {
|
||||
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
|
||||
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
|
||||
return Object.assign(def, saved);
|
||||
@@ -1330,7 +1370,7 @@
|
||||
loadSidebarConfig() {
|
||||
var self = this;
|
||||
try {
|
||||
var raw = {{ sidebar_config|default('{}')|safe }};
|
||||
var raw = {{ sidebar_config|default({})|tojson }};
|
||||
if (raw && raw.config) {
|
||||
self.sidebarConfig = raw.config;
|
||||
} else if (typeof raw === 'object') {
|
||||
@@ -1361,7 +1401,7 @@
|
||||
|
||||
getSectionOrder(key) {
|
||||
var cfg = this.sidebarConfig[key];
|
||||
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
|
||||
var sections = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
|
||||
if (cfg && typeof cfg.order === 'number') return cfg.order;
|
||||
return sections.indexOf(key);
|
||||
},
|
||||
@@ -1380,7 +1420,7 @@
|
||||
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
|
||||
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
|
||||
}) :
|
||||
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
|
||||
['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
|
||||
var idx = sections.indexOf(key);
|
||||
if (idx < 0) return;
|
||||
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
@@ -1422,7 +1462,7 @@
|
||||
getSectionIcon(key) {
|
||||
var icons = {
|
||||
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
|
||||
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
|
||||
agents: '🤖', teamspaces: '🏛️', shared: '👥', published: '🌐', gitea: '🔗',
|
||||
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
|
||||
help: '❓', trash: '🗑️'
|
||||
};
|
||||
@@ -1432,7 +1472,7 @@
|
||||
var labels = {
|
||||
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
|
||||
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
|
||||
shared: 'Shared', published: 'Published', private: 'Private',
|
||||
teamspaces: 'Teamspaces', shared: 'Shared', published: 'Published', private: 'Private',
|
||||
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
|
||||
help: 'Help', trash: 'Trash'
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
||||
contenteditable="true"
|
||||
@blur="updateField('body', $event.target.innerHTML)">
|
||||
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
|
||||
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Checklists -->
|
||||
@@ -82,7 +82,7 @@
|
||||
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
||||
{{ comment.body|safe }}
|
||||
{{ comment.body }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
@@ -150,7 +150,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!item) return;
|
||||
if (!confirm('Delete ' + item.name + '?')) return;
|
||||
var self = this;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
}).then(function(r){
|
||||
if (r.ok) { self.refreshTree(); }
|
||||
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!path) return;
|
||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
@@ -387,7 +387,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (!this.filePath) return;
|
||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -1198,7 +1198,7 @@ function libraryPage() {
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
var self = this;
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return;
|
||||
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
|
||||
@@ -1218,7 +1218,7 @@ function libraryPage() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
|
||||
var self = this;
|
||||
// Soft-delete all selected items
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
|
||||
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
}
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
@@ -1124,7 +1124,8 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
|
||||
body: JSON.stringify({icon: icon})
|
||||
});
|
||||
if (!r.ok) throw new Error('icon update failed');
|
||||
@@ -1206,7 +1207,7 @@ window._wsInitData = (function() {
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1343,7 +1344,7 @@ window._wsInitData = (function() {
|
||||
if (!newName) return;
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1523,7 +1524,7 @@ window._wsInitData = (function() {
|
||||
this.renamingId = null;
|
||||
if (!n || n === node.name) return;
|
||||
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -1578,7 +1579,7 @@ window._wsInitData = (function() {
|
||||
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
||||
this.clipboard.forEach(function(id) {
|
||||
fetch('/api/local-workspace/items/' + id + '/move', {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({parent_id: targetId})
|
||||
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
||||
});
|
||||
@@ -1589,7 +1590,7 @@ window._wsInitData = (function() {
|
||||
// ── Duplicate ──
|
||||
async duplicateItem(node) {
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
||||
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
||||
});
|
||||
@@ -1617,7 +1618,7 @@ window._wsInitData = (function() {
|
||||
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
||||
this.undoVisible = true;
|
||||
// Delete via API
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (!r.ok) { this.undoVisible = false; return; }
|
||||
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
||||
this._reloadAfterAction();
|
||||
@@ -1642,7 +1643,7 @@ window._wsInitData = (function() {
|
||||
self._reloadAfterAction();
|
||||
return;
|
||||
}
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
||||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
|
||||
.then(function(r) { if (r.ok) restored++; })
|
||||
.finally(function() { restoreOne(i + 1); });
|
||||
}
|
||||
@@ -1940,7 +1941,7 @@ window._wsInitData = (function() {
|
||||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||||
var r = await fetch('/api/local-workspace/items', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2024,7 +2025,7 @@ window._wsInitData = (function() {
|
||||
if (!n||!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:n})
|
||||
});
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
@@ -2037,7 +2038,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async doDelete() {
|
||||
if (!this.target) return;
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
|
||||
if (r.ok) { this._reloadAfterAction(); }
|
||||
},
|
||||
|
||||
@@ -2230,7 +2231,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2326,7 +2327,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: tagName})
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2345,7 +2346,7 @@ window._wsInitData = (function() {
|
||||
|
||||
async removeTag(itemId, tagId) {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
|
||||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -2491,7 +2492,7 @@ window._wsInitData = (function() {
|
||||
try {
|
||||
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ parent_id: parentId || null })
|
||||
});
|
||||
} catch(e) {}
|
||||
|
||||
+83
-30
@@ -810,35 +810,57 @@
|
||||
<!-- Admin: Audit Log -->
|
||||
<div x-show="activeSection==='admin-audit'">
|
||||
<h2>Audit Log</h2>
|
||||
<p class="section-desc">Login history and security events.</p>
|
||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||||
</template>
|
||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
|
||||
<span style="flex:1;"></span>
|
||||
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>User</th>
|
||||
<th>IP Address</th>
|
||||
<th>User Agent</th>
|
||||
<th>Source</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Resource</th>
|
||||
<th>IP</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="e in auditEntries" :key="e.id">
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
|
||||
<td>
|
||||
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
|
||||
</td>
|
||||
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
|
||||
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
|
||||
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="auditEntries.length === 0">
|
||||
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
|
||||
<tr x-show="!auditLogs.length && !auditLoading">
|
||||
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||||
</div>
|
||||
<style>
|
||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
|
||||
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
|
||||
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Backups -->
|
||||
@@ -1203,6 +1225,7 @@ function settingsInit() {
|
||||
adminUsers: [],
|
||||
adminStats: {},
|
||||
auditEntries: [],
|
||||
auditLogs: [],
|
||||
showCreateUser: false,
|
||||
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
|
||||
editingUser: null,
|
||||
@@ -1314,7 +1337,7 @@ function settingsInit() {
|
||||
var n = this.newTagName.trim();
|
||||
if (!n) return;
|
||||
var r = await fetch('/api/settings/tags', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name: n, color: this.newTagColor})
|
||||
});
|
||||
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||
@@ -1322,7 +1345,7 @@ function settingsInit() {
|
||||
|
||||
async updateTagColor(id, color) {
|
||||
await fetch('/api/settings/tags/' + id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
await this.loadTags();
|
||||
@@ -1330,7 +1353,7 @@ function settingsInit() {
|
||||
|
||||
async deleteTag(id) {
|
||||
if (!confirm('Delete this tag?')) return;
|
||||
await fetch('/api/settings/tags/' + id, { method: 'DELETE' });
|
||||
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
@@ -1352,7 +1375,7 @@ function settingsInit() {
|
||||
this.renamingTag = null; return;
|
||||
}
|
||||
await fetch('/api/settings/tags/' + tag.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
this.renamingTag = null;
|
||||
@@ -1394,11 +1417,41 @@ function settingsInit() {
|
||||
},
|
||||
|
||||
async loadAdminAudit() {
|
||||
await this.loadAuditLogs();
|
||||
},
|
||||
|
||||
// ── Unified Audit API (v7.3.0) ──
|
||||
auditSource: 'all',
|
||||
auditActor: '',
|
||||
auditAction: '',
|
||||
auditOffset: 0,
|
||||
auditPageSize: 50,
|
||||
auditLoading: false,
|
||||
auditHasMore: false,
|
||||
async loadAuditLogs(reset) {
|
||||
if (reset !== false) { this.auditOffset = 0; }
|
||||
this.auditLoading = true;
|
||||
try {
|
||||
var r = await this.adminFetch('/api/admin/audit?limit=200');
|
||||
var d = await r.json();
|
||||
this.auditEntries = d.entries || [];
|
||||
} catch(e) { this.auditEntries = []; }
|
||||
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
|
||||
if (this.auditActor) params.set('actor', this.auditActor);
|
||||
if (this.auditAction) params.set('action', this.auditAction);
|
||||
const r = await fetch('/api/v2/audit/logs?' + params.toString());
|
||||
if (!r.ok) { throw new Error('HTTP ' + r.status); }
|
||||
const d = await r.json();
|
||||
const logs = d.logs || [];
|
||||
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
|
||||
this.auditHasMore = logs.length >= this.auditPageSize;
|
||||
} catch(e) {
|
||||
if (reset !== false) this.auditLogs = [];
|
||||
this.auditHasMore = false;
|
||||
}
|
||||
this.auditLoading = false;
|
||||
},
|
||||
auditCsvUrl() {
|
||||
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
|
||||
if (this.auditActor) params.set('actor', this.auditActor);
|
||||
if (this.auditAction) params.set('action', this.auditAction);
|
||||
return '/api/v2/audit/logs?' + params.toString();
|
||||
},
|
||||
|
||||
async adminCreateUser() {
|
||||
@@ -1595,7 +1648,7 @@ function settingsInit() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1624,7 +1677,7 @@ function settingsInit() {
|
||||
if (f.models && f.models.length) body.models = f.models;
|
||||
var r = await fetch('/api/agent/keys/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1660,7 +1713,7 @@ function settingsInit() {
|
||||
if (f.api_key) body.api_key = f.api_key;
|
||||
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -1686,7 +1739,7 @@ function settingsInit() {
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id, { method: 'DELETE' });
|
||||
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
||||
@@ -1846,7 +1899,7 @@ function settingsInit() {
|
||||
if (!file) return;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var r = await fetch('/api/settings/avatar', { method: 'POST', body: form });
|
||||
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||
@@ -1857,7 +1910,7 @@ function settingsInit() {
|
||||
async selectAvatarColor(color) {
|
||||
this.avatarColor = color;
|
||||
var r = await fetch('/api/settings/avatar-color', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({color: color})
|
||||
});
|
||||
if (r.ok) { this.avatarUrl = ''; }
|
||||
@@ -2002,7 +2055,7 @@ function settingsInit() {
|
||||
// ── v5.2.0 API tokens ──
|
||||
async loadApiTokens() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {credentials:'same-origin'});
|
||||
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.apiTokens = d.tokens || [];
|
||||
} catch(e) { this.apiTokens = []; }
|
||||
@@ -2012,7 +2065,7 @@ function settingsInit() {
|
||||
if (!name) return;
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
||||
this.newToken = d;
|
||||
|
||||
@@ -155,7 +155,7 @@ function onboarding() {
|
||||
async createWorkspace() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
|
||||
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({name:this.wsName.trim()})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.workspaceId = d.id;
|
||||
@@ -172,7 +172,7 @@ function onboarding() {
|
||||
async createProject() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
|
||||
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
|
||||
this.toast('Projet créé 🎉');
|
||||
|
||||
@@ -174,7 +174,7 @@ function workspacePage() {
|
||||
if (!this.newProjectName.trim()) return;
|
||||
const r = await fetch('/api/workspace/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: this.newProjectName.trim()})
|
||||
});
|
||||
if (r.ok) {
|
||||
|
||||
@@ -214,7 +214,7 @@ function workspacesPage() {
|
||||
},
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
@@ -222,7 +222,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -240,7 +240,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -251,7 +251,7 @@ function workspacesPage() {
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
|
||||
|
||||
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
|
||||
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
|
||||
les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
# V6.8.0 — Sites & Forms publics
|
||||
|
||||
> **Statut** : ✅ Livré en v6.8.0 (2026-09-28) — `app/routers/sites.py`, migration 24, 20 tests verts · **Roadmap** : `ROADMAP.md § v6.8.0`
|
||||
> **Référence Notion** : Sites (multi-pages, domaine custom, SEO, analytics, password/expiry) + Forms (soumission anonyme → DB, embed, notifs).
|
||||
> **Existant réutilisé** : page publique `/p/<slug>` (`dashboard.py`), `_render_blocks_public`, `export.py` (récursif + images), `validate_property_rule` (`property_types.py`), `notifications` + `mailer.py`, vue Form (`_database_table_scripts.html`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
Passer de « une page publiée isolée » à « publier un mini-site » et « collecter des réponses » :
|
||||
`page privée → site (arbre + thème + gating) → URL publique /s/<slug>` et
|
||||
`collection → form public /f/<token> → ligne collection_pages`.
|
||||
|
||||
Hors scope : builder drag&drop marketing, paiement, commentaires publics.
|
||||
|
||||
## 2. Sites multi-pages
|
||||
|
||||
### 2.1 Schéma (migration 24)
|
||||
|
||||
```sql
|
||||
CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE, -- /s/<slug>
|
||||
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
theme TEXT NOT NULL DEFAULT 'dark', -- light|dark
|
||||
custom_domain TEXT UNIQUE, -- Host header match
|
||||
password_hash TEXT, -- NULL = public
|
||||
expires_at TIMESTAMP, -- NULL = jamais
|
||||
noindex BOOLEAN NOT NULL DEFAULT 0,
|
||||
analytics_id TEXT DEFAULT '', -- Plausible/GA, pas d'IP brute
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE site_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(site_id, page_id)
|
||||
);
|
||||
CREATE TABLE site_views (
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
day TEXT NOT NULL, -- YYYY-MM-DD UTC
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (site_id, day)
|
||||
);
|
||||
```
|
||||
|
||||
Règles : arbre construit depuis `site_pages` ordonné ; page hors site → 404 ; page supprimée → exclue + état « Deleted » ; `resolve_content_json()` (synced) appliqué comme `/p/<slug>`.
|
||||
|
||||
### 2.2 Gating & SEO
|
||||
|
||||
- Mot de passe : bcrypt, cookie signé `site_auth_<id>` 24h, `GET/POST /s/<slug>/auth`.
|
||||
- Expiry : `expires_at < now` → 410 « Site expiré ».
|
||||
- SEO : `<title>`, meta description (1er paragraphe), OG/Twitter (cover+icône), `sitemap.xml`, `robots.txt` (`noindex` → `noindex,nofollow`).
|
||||
- Stats : `+1/jour/site` sur chaque vue (upsert), `GET /api/v2/sites/{id}/stats?days=30`.
|
||||
|
||||
### 2.3 Routes
|
||||
|
||||
```
|
||||
GET /s/<slug> → home (root_page + nav)
|
||||
GET /s/<slug>/<page-slug> → page du site (nav active)
|
||||
GET /s/<slug>/sitemap.xml
|
||||
GET|POST /s/<slug>/auth → gate password
|
||||
GET|POST|PATCH|DELETE /api/v2/sites
|
||||
GET|POST|DELETE /api/v2/sites/{id}/pages
|
||||
GET /api/v2/sites/{id}/stats
|
||||
```
|
||||
|
||||
Domaine custom : si `Host == custom_domain` → monte le site sans `/s/<slug>`.
|
||||
|
||||
## 3. Forms publics
|
||||
|
||||
### 3.1 Config (colonne `collections.form_config_json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true, "public_token": "f_abc123",
|
||||
"title": "Contact", "success_message": "Merci !",
|
||||
"fields": ["Name", "Email", "Message"],
|
||||
"required": ["Name", "Email"],
|
||||
"notify_user_ids": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
Table `form_responses (id, collection_id, row_id, ip_hash, created_at)` — `ip_hash` = sha256(IP+jour), jamais d'IP brute.
|
||||
|
||||
### 3.2 Soumission anonyme
|
||||
|
||||
```
|
||||
GET /f/<token> → formulaire thèmable (no-auth, `?embed=1` sans chrome)
|
||||
POST /f/<token> → valide via validate_property_rule → 400 + messages
|
||||
rate-limit 20/h/IP → 429, honeypot __hp + Turnstile optionnel
|
||||
→ collection_pages + form_responses + notif in-app/email
|
||||
```
|
||||
|
||||
Trigger automation `form.submitted` (v7.0.0 s'y branchera).
|
||||
|
||||
Embed : `<iframe src="https://host/f/<token>?embed=1" width="100%" height="600">`.
|
||||
|
||||
## 4. UI
|
||||
|
||||
- Éditeur « … » → `Share → Publish → Site` : créer site, choisir pages, thème, password/expiry/noindex, copier URL + snippet.
|
||||
- Collection → `Views → Form → Share form` : toggle public, champs affichés, message succès, liste réponses (compteur + lien lignes filtrées `form:true`).
|
||||
- Settings → Sites (liste, stats sparkline, domaine custom, revoke).
|
||||
|
||||
## 5. Sécurité / perfs
|
||||
|
||||
- Password bcrypt cost 12, cookie `itsdangerous` signé, CSRF exempt uniquement `POST /f/<token>` (rate-limit + honeypot compensent).
|
||||
- `custom_domain` validé (hostname, pas d'IP privée) ; `slug` `^[a-z0-9-]{3,50}$`.
|
||||
- Cache rendu public 60s (`Cache-Control: public, max-age=60`), stats en upsert (pas de ligne/vue).
|
||||
|
||||
## 6. Tests (`tests/test_v68_sites_forms.py`, ~25)
|
||||
|
||||
CRUD site, nav ordonnée, 404 hors-site, password OK/KO + cookie, expiry 410, noindex meta, sitemap, stats +1/jour, domaine custom (Host override), form GET anonyme, POST valide → ligne, POST invalide → 400, rate-limit 429, honeypot 400, embed `?embed=1`, notif créée, ACL (non-owner 403/404).
|
||||
|
||||
## 7. Rollout
|
||||
|
||||
1. Migration 24 + CRUD API + tests. 2. Rendu `/s/` + gating + SEO. 3. Forms + embed + notifs. 4. UI Share/Site + docs `/help` + OpenAPI.
|
||||
@@ -0,0 +1,69 @@
|
||||
# V6.9.0 — Recherche sémantique + Ask AI
|
||||
|
||||
> **Statut** : ✅ Livré en v6.9.0 (2026-09-28) — `app/services/semantic_search.py`, `app/routers/search_ai.py`, migration 25, 24 tests verts · **Roadmap** : `ROADMAP.md § v6.9.0`
|
||||
> Note d'implémentation : table générique `semantic_embeddings(resource_type, resource_id, …)` au lieu de `page_embeddings(page_id, …)` — couvre aussi les collections ; encodeur hashed-TF `hash-256` (pas d'appel `/embeddings` externe) ; pièces jointes et onglets palette en follow-up.
|
||||
> **Référence Notion** : Enterprise Search (Notion + Slack/Jira/Drive) + AI Q&A avec citations.
|
||||
> **Existant** : FTS5 (`services/search.py`, `GET /api/search`, `GET /api/v2/search`), palette `Ctrl+K`, `AgentEngine` (ReAct 12 tours), `PermissionManager`, `llm_config` + `LLMClient` (9 providers).
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
`question → top-k chunks autorisés → réponse avec [[fdpage:ID]] cliquables`.
|
||||
Hybride lexical + vectoriel (RRF), jamais de fuite ACL (filtrage avant prompt).
|
||||
|
||||
Hors scope : index temps réel < 1s, connecteurs Slack/Jira (v7.x si besoin forge d'abord).
|
||||
|
||||
## 2. Indexation
|
||||
|
||||
### 2.1 Schéma (migration 25)
|
||||
|
||||
```sql
|
||||
CREATE TABLE page_embeddings (
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
chunk_id INTEGER NOT NULL, -- index du chunk (0..N)
|
||||
chunk_text TEXT NOT NULL,
|
||||
embedding BLOB NOT NULL, -- float32 serialisé
|
||||
model TEXT NOT NULL DEFAULT 'local-tfidf',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (page_id, chunk_id)
|
||||
);
|
||||
CREATE TABLE search_index_state (page_id INTEGER PRIMARY KEY, indexed_at TIMESTAMP);
|
||||
ALTER TABLE pages ADD COLUMN search_excluded BOOLEAN NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
- Chunking : blocs → texte brut (~500 tokens, overlap 50), ignore `embed`/images (légende seule).
|
||||
- Providers : `openai-compatible /embeddings` via `LLMClient` si clé user/workspace, sinon **TF-IDF local** (zéro dépendance, FR+EN stopwords) — même interface `embed(texts) -> vectors`.
|
||||
- Job : scheduler 5 min, batch 50 (`updated_at > indexed_at`), `DELETE` embeddings si page trashée/exclue.
|
||||
|
||||
### 2.2 Contenus indexés
|
||||
|
||||
Pages `blocks` + `markdown`/`file` (résolution déjà `export.py`), pièces jointes texte (pdf via `pypdf`, docx via `python-docx`, txt/md — cap 1 MB/fichier), titres de lignes DB (sans valeurs privées si `property_permissions` restreint → chunk « titre seul »), issues Gitea/GitHub liées (titre + labels, pas de body privé sans grant).
|
||||
|
||||
## 3. Recherche hybride
|
||||
|
||||
`GET /api/v2/search/hybrid?q=&workspace_id=&type=&limit=20` :
|
||||
1. FTS5 score (existants) ; 2. cosine top-50 ; 3. fusion RRF `k=60` ; 4. filtre ACL (`can_view_page`/`can_view_collection`) ; 5. `X-Total-Count`.
|
||||
Filtres : `verified:1`, `type:page|collection|file`, `after:YYYY-MM-DD`.
|
||||
Palette `Ctrl+K` : onglets `Pages / Fichiers / ✨ Réponses IA`.
|
||||
|
||||
## 4. Ask AI
|
||||
|
||||
`POST /api/v2/search/ask {question, workspace_id}` → `{answer_markdown, citations: [{page_id, title}]}` :
|
||||
- top-k=8 chunks autorisés → prompt `system: réponds en français, cite [[fdpage:ID]]` → `LLMClient.chat()` (clé user, sinon mock déterministe « hors-ligne » comme `ai_writing.py`).
|
||||
- Budget : 4k tokens contexte, timeout 60s, cache `(hash(question)+workspace)` 10 min.
|
||||
- Citations résolues via `wiki/titles` (renommage propagé, « Deleted page » gérée).
|
||||
|
||||
## 5. UI / perfs / sécu
|
||||
|
||||
- Badge `✨` + temps de réponse, feedback 👍/👎 (`ask_feedback` log, futur fine-tune).
|
||||
- ACL avant LLM (jamais de chunk interdit dans le prompt) ; `search_excluded` respecté partout ; audit `api_audit_log`.
|
||||
- Perfs : embeddings lazy (pas au save, job fond), cosine en numpy si dispo sinon pur Python (DB < 10k pages OK).
|
||||
|
||||
## 6. Tests (`tests/test_v69_search_ask.py`, ~20)
|
||||
|
||||
Index chunks, hybride RRF ordre, ACL (page restricted exclue), `verified` filtre, exclusion flag, pièces jointes cap, ask citations valides, ask sans clé → fallback, cache hit, rate-limit, `X-Total-Count`.
|
||||
|
||||
## 7. Rollout
|
||||
|
||||
1. `embed()` + TF-IDF + job. 2. `/hybrid` + palette. 3. `/ask` + cache + feedback. 4. Docs `/help` + OpenAPI.
|
||||
@@ -0,0 +1,71 @@
|
||||
# V7.0.0 — Automations v2 + Workers
|
||||
|
||||
> **Statut** : ✅ Livré en v7.0.0 (2026-09-28) — `automation_steps` + 4 nouvelles actions + `services/workers.py` + `routers/workers.py`, migration 26, 31 tests verts · **Roadmap** : `ROADMAP.md § v7.0.0`
|
||||
> Notes d'implémentation : legacy sans steps = fallback inchangé (pas de migration de données) ; `delay` = sleep plafonné 300s (pas de re-queue) ; `agent_trigger` = run synchrone (timeout `agent_run_timeout_seconds`) ; éditeur visuel canvas en follow-up.
|
||||
> **Référence Notion** : Database automations (multi-triggers any/all, chaînes) + Buttons + Workers (custom code, credits dashboard, partage équipe, 07/2026).
|
||||
> **Existant** : `automations` + `automation_runs` (scheduler 60s), triggers `page.*/collection.*` + cron + bouton, conditions `eq/neq/contains/...`, actions `webhook/set_property/create_page/notify`, bloc `button`, webhooks v2 HMAC + retry.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
De `si X alors Y` à `quand (A ou B) et si C alors [Y1 → délai → Y2]`, plus `code custom` sandboxé :
|
||||
`trigger(s) → conditions → steps → runs avec logs par step`.
|
||||
|
||||
## 2. Modèle (migration 26)
|
||||
|
||||
```sql
|
||||
CREATE TABLE automation_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, -- trigger|condition|delay|action
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
config_json TEXT NOT NULL DEFAULT '{}'
|
||||
-- trigger: {event: page.created|form.submitted|meeting.summarized|site.viewed|...,
|
||||
-- mode: any|all (au niveau automation)}
|
||||
-- condition: {property, operator, value} (AND entre steps condition)
|
||||
-- delay: {seconds|minutes|hours|days}
|
||||
-- action: {type: webhook|set_property|create_page|notify|slack|email|forge_issue|agent_trigger,
|
||||
-- ...params + interpolation [[prop]]/{{title}}}
|
||||
);
|
||||
CREATE TABLE workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE, workspace_id INTEGER REFERENCES workspaces(id),
|
||||
name TEXT NOT NULL, code_py TEXT NOT NULL DEFAULT '',
|
||||
schedule_cron TEXT, shared BOOLEAN NOT NULL DEFAULT 0,
|
||||
daily_budget_s INTEGER NOT NULL DEFAULT 60,
|
||||
created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE worker_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL, -- ok|error|timeout|over_budget
|
||||
logs TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
ALTER TABLE collection_properties ADD COLUMN button_automation_id INTEGER REFERENCES automations(id);
|
||||
```
|
||||
|
||||
Compat : automations v5.1.0 existantes migrées en 1 step trigger + 1 step action.
|
||||
|
||||
## 3. Sémantique d'exécution
|
||||
|
||||
- `mode any` (défaut) : n'importe quel step trigger déclenche ; `all` : tous les events doivent arriver dans la fenêtre 5 min (état `automation_runs`).
|
||||
- Nouveaux triggers : `form.submitted` (v6.8), `meeting.summarized` (v7.1), `site.viewed` (seuil 100 vues/jour), `agent.run.failed`.
|
||||
- Actions : `slack` (incoming webhook URL chiffrée), `email` (via `mailer.py`), `forge_issue` (Gitea/GitHub `owner/repo`, titre/body/labels), `agent_trigger` (`agent_id` + input), `delay` (re-queue run, pas de sleep).
|
||||
- Scheduler existant étendu : évalue steps en ordre, log `automation_runs` enrichi `{step_id, in, out, ms}`.
|
||||
|
||||
## 4. Bouton DB natif
|
||||
|
||||
Propriété `button` : `{label, automation_id}` ; rendu cliquable en table/board/gallery ; `POST /api/automations/{id}/run` (déjà CSRF-exempt) + garde ACL écriture ligne ; optimistic toast.
|
||||
|
||||
## 5. Workers lite
|
||||
|
||||
- Code Python restreint : pas d'import réseau/`os`/`subprocess`, timeout 30s, mémoire cap, allowlist `httpx` vers `Gitea/GitHub` + webhooks sortants uniquement.
|
||||
- Déclenchement : cron (`schedule_cron`) ou manuel ou `agent_trigger` ; `shared=1` → fork 1-clic (comme Notion Workers partagés).
|
||||
- Compteur usage : `SUM(duration_ms)/jour/workspace` affiché façon credits dashboard.
|
||||
|
||||
## 6. UI / sécu / tests
|
||||
|
||||
- Settings → Automations : canvas vertical triggers → conditions → actions (Alpine.js, composants `components.css`), test-run avec payload sample, historique par step repliable.
|
||||
- Secrets (Slack URL, tokens) chiffrés Fernet comme `sso_config` ; champ vide = conserver.
|
||||
- Tests `tests/test_v70_automations_workers.py` (~25) : any/all, chaîne + delay (clock injectée), slack/email/forge/agent mocks, button ACL, worker timeout/over_budget/partage, migration v5 compat.
|
||||
@@ -0,0 +1,43 @@
|
||||
# V7.1.0 — Calendar sync + Meeting Notes
|
||||
|
||||
> **Statut** : ✅ Livré en v7.1.0 (2026-09-28) — `services/calendar_sync.py`, `services/meetings.py`, `routers/meetings.py`, migration 27, 15 tests verts · **Roadmap** : `ROADMAP.md § v7.1.0`
|
||||
> Notes d'implémentation : pas de lib CalDAV/Google (REST + XML bruts) ; refresh OAuth hors scope (relink explicite sur 401) ; conflit = LWW auto + notif (pas de picker « garder ma version ») ; free/busy au jour (pas de créneaux horaires).
|
||||
> **Référence Notion** : Notion Calendar (Google sync) + AI Meeting Notes → trigger Custom Agents (07/2026).
|
||||
> **Existant** : vues jour/semaine/mois, moteur `recurrence.py`, `reminders.py` (scan 60s, dédup), timezones, `notify_assignment()`, template Meeting Notes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sync externe bidirectionnelle
|
||||
|
||||
```sql
|
||||
CREATE TABLE calendar_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL, -- google|caldav
|
||||
tokens_enc TEXT NOT NULL DEFAULT '', -- Fernet (refresh + access)
|
||||
calendar_id TEXT NOT NULL DEFAULT 'primary',
|
||||
sync_token TEXT DEFAULT '',
|
||||
last_sync TIMESTAMP, UNIQUE(user_id, provider, calendar_id)
|
||||
);
|
||||
ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT;
|
||||
CREATE TABLE meeting_transcripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
audio_path TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '', language TEXT DEFAULT 'fr',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
- OAuth Google (`app/auth/providers/google_calendar.py`, scope `calendar.events`) + CalDAV générique (URL/user/pass chiffrés).
|
||||
- Pull 15 min : event externe → page (titre + `date` + `__external_id__`) ; push : occurrence FlowDeck → event (si `sync_enabled` par collection).
|
||||
- Conflit (modifié des 2 côtés depuis `last_sync`) → LWW + notif `calendar.conflict` + lien « garder ma version / prendre externe ».
|
||||
- `GET /db/{id}/calendar/freebusy?attendees=&from=&to=` : créneaux libres (jour ouvré 9h-18h, fuseau user).
|
||||
|
||||
## 2. Meeting Notes v2
|
||||
|
||||
Upload `.mp3/.wav/.m4a` (100 MB) sur page Meeting → `meeting_transcripts` → transcription (Whisper local si binaire présent, sinon API via `llm_config`, timeout 10 min, job fond + polling) → résumé `ai_writing.py` (`Décisions`, `Next steps [[person]]`, `Action items`) inséré en blocs + `Agenda/Notes` existants complétés.
|
||||
Émet `meeting.summarized` → automations v7.0 (update tracker, post Slack, file tickets).
|
||||
|
||||
## 3. Tests (`tests/test_v71_calendar_meetings.py`, ~15)
|
||||
|
||||
OAuth mock (tokens chiffrés roundtrip), pull/push, conflit LWW + notif, freebusy, transcription mock → résumé blocs, trigger émis, quota 100 MB refusé.
|
||||
@@ -0,0 +1,35 @@
|
||||
# V7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI
|
||||
|
||||
> **Statut** : 📝 Planifié · **Roadmap** : `ROADMAP.md § v7.2.0`
|
||||
> **Référence Notion** : SCIM, audit log, domain verification, agent permissions/governance.
|
||||
> **Existant** : SSO SAML/OIDC + provisioning + `sso_only` (v6.7.0), `api_audit_log` + `permission_audit_log` + `sso_login_history` (API seule), `SessionManager`, `PermissionManager`.
|
||||
|
||||
---
|
||||
|
||||
## 1. SCIM 2.0 (provisioning auto)
|
||||
|
||||
`GET|POST /scim/v2/Users`, `GET|PUT|PATCH|DELETE /scim/v2/Users/{id}` (Bearer `scim_tokens`, admin) :
|
||||
mapping `userName→login`, `emails[0]→email`, `name→full_name`, `active→users.is_active`.
|
||||
`active=false` → `is_active=0`, sessions révoquées, login refusé (401 « suspended ») sauf admin.
|
||||
Groupes IdP → `sync_sso_permissions()` existant réutilisé.
|
||||
|
||||
## 2. 2FA + passkeys
|
||||
|
||||
- TOTP : `users.totp_secret_enc` (Fernet), setup QR (`otpauth://`), vérif au login après password (fenêtre ±1), 10 backup codes (`sha256`, usage unique).
|
||||
- WebAuthn : `webauthn_credentials (id, user_id, credential_id, public_key, sign_count)` ; login `password + key` ou `passkey seule` si `passwordless_allowed`.
|
||||
- Combinable avec `sso_only` (local password refusé, TOTP conservé si `require_2fa=1`).
|
||||
|
||||
## 3. Domain claim + audit UI
|
||||
|
||||
- `domain_claims (domain UNIQUE, txt_token, verified BOOL, auto_join_role, enforce_sso BOOL)` ; vérif DNS TXT ; `enforce_sso` → login local du domaine redirigé IdP.
|
||||
- Settings → Audit : table unifiée (`api_audit_log` + `permission_audit_log` + `sso_login_history` + `worker_runs`), filtres acteur/ressource/date, export CSV (10k max), rétention 365j (purge scheduler).
|
||||
|
||||
## 4. Gouvernance agents
|
||||
|
||||
`agent_policies (workspace_id, allowed_tools_json, max_steps, require_approval BOOL)` + `agent_approvals (action_id, status pending/approved/rejected, approver_id)` :
|
||||
si `require_approval` et outil write → `agent.run.approval_requested` (webhook + cloche) → exécution après approve, sinon 403. File Settings → Agents.
|
||||
|
||||
## 5. Migrations 28 + tests (~25)
|
||||
|
||||
`scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, `users.is_active/totp_secret_enc`.
|
||||
Tests : SCIM CRUD + suspend, TOTP ±fenêtre + backup, WebAuthn mock, domain TXT mock, audit filtres/export, policies approve/reject.
|
||||
@@ -0,0 +1,72 @@
|
||||
# V7.3.0 — Wiki / Teamspaces + Polish
|
||||
|
||||
> **Statut** : ✅ Implémenté (2026-09-29) — 72 tests · suite complète **1016 verts** · **Roadmap** : `ROADMAP.md § v7.3.0`
|
||||
> **Référence Notion** : Wikis + Teamspaces + Verified pages + Charts/Number + réactions/follow/guests + page analytics.
|
||||
> **Existant** : workspaces + rôles, backlinks, wiki-links `[[`, `collection_dashboards`, Chart.js, comments, `site_views`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Teamspaces + Verified
|
||||
|
||||
```sql
|
||||
CREATE TABLE teamspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, private BOOLEAN NOT NULL DEFAULT 0, UNIQUE(workspace_id, name));
|
||||
CREATE TABLE teamspace_members (teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id), role TEXT NOT NULL DEFAULT 'editor', UNIQUE(teamspace_id, user_id));
|
||||
ALTER TABLE pages ADD COLUMN teamspace_id INTEGER REFERENCES teamspaces(id);
|
||||
ALTER TABLE collections ADD COLUMN teamspace_id INTEGER REFERENCES teamspaces(id);
|
||||
CREATE TABLE page_verifications (
|
||||
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
|
||||
verified_by INTEGER REFERENCES users(id), note TEXT DEFAULT '',
|
||||
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP);
|
||||
```
|
||||
|
||||
- `private=1` sans membership → 404 (comme collections restricted) ; `PermissionManager.get_teamspace_role()`.
|
||||
- Badge ✅ (owner/editor vérifie, expiry 90j défaut, job notif owner à J-7) ; filtre `verified:1` en search + palette ; page `/wiki` auto (verified + recents + teamspaces).
|
||||
|
||||
## 2. Blocs & charts
|
||||
|
||||
- Bloc `mermaid` : code → SVG serveur si `mmdc` présent, sinon fallback bloc code + rendu client (déjà Prism) ; export HTML/PDF embarque le SVG.
|
||||
- `equation_inline` : KaTeX inline (lib déjà self-hostée `static/js/katex.min.js`).
|
||||
- Bloc `progress` : `{rollup_ref}` → barre % (réutilise `RollupEngine`).
|
||||
- Charts : type `number` (big KPI), dashboards multi-DB en page (grille `collection_dashboards` + source `any`, limite 200 groupes documentée), « ouvrir la ligne » depuis chart.
|
||||
- **Livré (follow-up)** : `number` = KPI `count|sum|avg|min|max` (`_chart_aggregate`/`_fmt_number`) ; rendu grille `GET /db/{collection_id}/dashboards/{dashboard_id}` — widgets à `collection_id` (= source `any`), ≤ 40 widgets, ≤ 200 lignes/chart (`CHART_MAX_GROUPS`) ; la config de vue est choisie selon le `view_type` demandé (bug corrigé : la première vue était toujours utilisée) ; un widget pointant sur une DB restreinte est sauté (pas de leak).
|
||||
|
||||
## 3. Collab polish
|
||||
|
||||
- `comment_reactions (comment_id, user_id, emoji, UNIQUE)` + picker emoji.
|
||||
- `page_follows (page_id, user_id)` → notif `page.updated` aux followers (pref `follows`, défaut ON si commenté).
|
||||
- **Livré (follow-up)** : `wiki.notify_followers_of_page_update` (throttle 10 min, `actor_id` exclu) branché dans `automations.fire_event` ; `actor_id` dans les payloads `board.update_page`/`save_page_blocks` ; auto-follow à la création d'un commentaire (`wiki.ensure_follow`).
|
||||
- Guests : `guest_shares (page_id, email, token, role viewer|commenter, expires_at)` — accès sans compte via `/g/<token>` (session guest limitée, auditée).
|
||||
- `page_views (page_id, day, views PK)` — même pattern `site_views`, exposé `GET /board/api/pages/{id}/views` (owner only).
|
||||
|
||||
## 4. Tests (`tests/test_v73_wiki_polish.py`, 72 ✅)
|
||||
|
||||
Teamspace private 404/grant, verify badge/expiry/notif, `/wiki` home, blocs
|
||||
mermaid/equation/progress + export, réactions, follow, guest token/expiry,
|
||||
page views. **Follow-ups** : sidebar cross-workspace (sans `workspace_id`),
|
||||
page HTML `/wiki/teamspaces/{id}` (owner 200 / outsider 404 API + non-leak
|
||||
HTML), auto-follow par commentaire, notif `page.updated` throttlée + acteur
|
||||
exclu, `ensure_follow` idempotent, regex + unfurl gitea/github (tokens
|
||||
mockés) + endpoint `/board/api/og/metadata`, KPI `number` + `_chart_values`
|
||||
(0 conservé) + dashboards multi-DB + 404.
|
||||
|
||||
## 5. Implémentation réelle vs. design initial
|
||||
|
||||
- **Livré** : schema §1 à l'identique ; routes sous `/api/v2/wiki/*` (le design
|
||||
parlait de `/wiki` comme page HTML : l'agrégat est exposé en JSON, le
|
||||
front Tri-state reste à faire) ; blocs §2 (`mermaid`, `equation_inline`,
|
||||
`progress`) rendus **côté serveur** par `app/services/wiki_blocks.py` et
|
||||
branchés sur `export.blocks_to_html` ; collab §3 complet.
|
||||
- **Follow-ups livrés** : sidebar par teamspace (section `base.html` +
|
||||
listing cross-workspace + page HTML), charts `number` + dashboards multi-DB,
|
||||
notif `page.updated` déclenchée à l'édition + auto-follow, unfurl
|
||||
`gitea:`/`github:` dans `POST /board/api/og/metadata` (champs bookmark
|
||||
persistés), UI Settings → Audit branchée sur `/api/v2/audit/logs`. Les
|
||||
21 casses SSO (`test_v67_sso.py`) sont corrigées (install
|
||||
`python3-saml==1.16.0` + `authlib==1.8.0`).
|
||||
- **Écart assumé** : `progress` agrège directement sur
|
||||
`collection_pages.property_values_json` plutôt que via `RollupEngine`, dont
|
||||
la signature exige une chaîne de relations + une page source — ce qu'un bloc
|
||||
de progression embarqué dans une page n'a pas.
|
||||
-1
@@ -1 +0,0 @@
|
||||
../@playwright/test/cli.js
|
||||
-1
@@ -1 +0,0 @@
|
||||
../playwright-core/cli.js
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
-318
@@ -1,318 +0,0 @@
|
||||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
|
||||
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
|
||||
|
||||
## Get Started
|
||||
|
||||
Choose the path that fits your workflow:
|
||||
|
||||
| | Best for | Install |
|
||||
|---|---|---|
|
||||
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
|
||||
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
|
||||
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
|
||||
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
|
||||
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
|
||||
|
||||
---
|
||||
|
||||
## Playwright Test
|
||||
|
||||
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
Or add manually:
|
||||
|
||||
```bash
|
||||
npm i -D @playwright/test
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Write a test
|
||||
|
||||
```TypeScript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
|
||||
|
||||
### Key capabilities
|
||||
|
||||
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
|
||||
|
||||
**Locators.** Find elements with resilient locators that mirror how users see the page:
|
||||
|
||||
```TypeScript
|
||||
page.getByRole('button', { name: 'Submit' })
|
||||
page.getByLabel('Email')
|
||||
page.getByPlaceholder('Search...')
|
||||
page.getByTestId('login-form')
|
||||
```
|
||||
|
||||
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
|
||||
|
||||
```TypeScript
|
||||
// Save state after login
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
|
||||
// Reuse in other tests
|
||||
test.use({ storageState: 'auth.json' });
|
||||
```
|
||||
|
||||
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
|
||||
|
||||
```TypeScript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of trace viewer -->
|
||||
|
||||
**Parallelism.** Tests run in parallel by default across all configured browsers.
|
||||
|
||||
[Full testing documentation](https://playwright.dev/docs/intro)
|
||||
|
||||
---
|
||||
|
||||
## Playwright CLI
|
||||
|
||||
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
Optionally install skills for richer agent integration:
|
||||
|
||||
```bash
|
||||
playwright-cli install --skills
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Point your coding agent at a task:
|
||||
|
||||
```
|
||||
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
|
||||
Take screenshots for all successful and failing scenarios.
|
||||
```
|
||||
|
||||
Or run commands directly:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
|
||||
playwright-cli type "Buy groceries"
|
||||
playwright-cli press Enter
|
||||
playwright-cli screenshot
|
||||
```
|
||||
|
||||
### Session monitoring
|
||||
|
||||
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
|
||||
|
||||
```bash
|
||||
playwright-cli show
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of playwright-cli show dashboard -->
|
||||
|
||||
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
|
||||
|
||||
---
|
||||
|
||||
## Playwright MCP
|
||||
|
||||
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
|
||||
|
||||
### Setup
|
||||
|
||||
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**One-click install for VS Code:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
**For Claude Code:**
|
||||
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
Ask your AI assistant to interact with any web page:
|
||||
|
||||
```
|
||||
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
|
||||
```
|
||||
|
||||
The agent sees the page as a structured accessibility tree:
|
||||
|
||||
```
|
||||
- heading "todos" [level=1]
|
||||
- textbox "What needs to be done?" [ref=e5]
|
||||
- listitem:
|
||||
- checkbox "Toggle Todo" [ref=e10]
|
||||
- text: "Buy groceries"
|
||||
```
|
||||
|
||||
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
|
||||
|
||||
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
|
||||
|
||||
---
|
||||
|
||||
## Playwright Library
|
||||
|
||||
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm i playwright
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Take a screenshot:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'screenshot.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Generate a PDF:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.pdf({ path: 'page.pdf', format: 'A4' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Emulate a mobile device:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium, devices } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices['iPhone 15']);
|
||||
const page = await context.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'mobile.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Intercept network requests:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
|
||||
await page.goto('https://playwright.dev/');
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
---
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
|
||||
|
||||
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
|
||||
|
||||
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
|
||||
|
||||
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
|
||||
|
||||
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
|
||||
|
||||
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
|
||||
|
||||
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
|
||||
|
||||
---
|
||||
|
||||
## Cross-Browser Support
|
||||
|
||||
| | Linux | macOS | Windows |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->153.0.8010.12<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| WebKit <!-- GEN:webkit-version -->26.6<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| Firefox <!-- GEN:firefox-version -->155.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|
||||
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
|
||||
|
||||
## Other Languages
|
||||
|
||||
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
|
||||
|
||||
## Resources
|
||||
|
||||
* [Documentation](https://playwright.dev)
|
||||
* [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
* [MCP server](https://github.com/microsoft/playwright-mcp)
|
||||
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
|
||||
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
|
||||
* [Contribution guide](CONTRIBUTING.md)
|
||||
* [Changelog](https://github.com/microsoft/playwright/releases)
|
||||
* [Discord](https://aka.ms/playwright/discord)
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { program } = require('playwright/lib/program');
|
||||
program.parse(process.argv);
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
module.exports = require('playwright/test');
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.63.0",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./cli": "./cli.js",
|
||||
"./package.json": "./package.json",
|
||||
"./reporter": "./reporter.js"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/types/testReporter';
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
# playwright-core
|
||||
|
||||
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
microsoft/playwright-core
|
||||
|
||||
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||
|
||||
This package bundles third-party software inside individual files under
|
||||
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
|
||||
to it listing every npm package whose source was inlined into that
|
||||
bundle, together with the full license text for each.
|
||||
|
||||
For example:
|
||||
- lib/utilsBundle.js.LICENSE
|
||||
|
||||
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
|
||||
# check if running on Windows Server
|
||||
if ($osInfo.ProductType -eq 3) {
|
||||
Install-WindowsFeature Server-Media-Foundation
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# This script sets up a WSL distribution that will be used to run WebKit.
|
||||
|
||||
$Distribution = "playwright"
|
||||
$Username = "pwuser"
|
||||
|
||||
$distributions = (wsl --list --quiet) -split "\r?\n"
|
||||
if ($distributions -contains $Distribution) {
|
||||
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
|
||||
} else {
|
||||
Write-Host "Installing new WSL distribution '$Distribution'..."
|
||||
$VhdSize = "10GB"
|
||||
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
|
||||
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
|
||||
}
|
||||
|
||||
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
|
||||
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
|
||||
|
||||
$initScript = @"
|
||||
if [ ! -f "/home/$Username/node/bin/node" ]; then
|
||||
mkdir -p /home/$Username/node
|
||||
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
|
||||
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
|
||||
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
|
||||
fi
|
||||
/home/$Username/node/bin/node cli.js install-deps webkit
|
||||
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
|
||||
"@ -replace "\r\n", "`n"
|
||||
|
||||
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
|
||||
Write-Host "Done!"
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome-beta
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome beta from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-beta_current_amd64.deb
|
||||
rm -rf ./google-chrome-beta_current_amd64.deb
|
||||
cd -
|
||||
google-chrome-beta --version
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome Beta.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
|
||||
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
|
||||
hdiutil detach /Volumes/googlechromebeta.dmg
|
||||
rm -rf /tmp/googlechromebeta.dmg
|
||||
|
||||
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
|
||||
|
||||
Write-Host "Downloading Google Chrome Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome stable from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||
rm -rf ./google-chrome-stable_current_amd64.deb
|
||||
cd -
|
||||
google-chrome --version
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
|
||||
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
|
||||
hdiutil detach /Volumes/googlechrome.dmg
|
||||
rm -rf /tmp/googlechrome.dmg
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
|
||||
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome.msi"
|
||||
Write-Host "Downloading Google Chrome"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
|
||||
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-beta
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-beta
|
||||
|
||||
microsoft-edge-beta --version
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_beta.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_beta.pkg -target /
|
||||
rm -rf /tmp/msedge_beta.pkg
|
||||
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old dev if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-dev
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-dev
|
||||
|
||||
microsoft-edge-dev --version
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_dev.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_dev.pkg -target /
|
||||
rm -rf /tmp/msedge_dev.pkg
|
||||
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Dev"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Dev"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-stable
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-stable
|
||||
|
||||
microsoft-edge-stable --version
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_stable.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_stable.pkg -target /
|
||||
rm -rf /tmp/msedge_stable.pkg
|
||||
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user