Compare commits

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

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

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00
bruno ffa1fa89ab fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00
bruno 3ad2605c9e fix: A14 — fin du fallback « row admin » sur l'agent (v7.3.7)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 20m33s
FlowDeck CI / docker (push) Canceled after 0s
- `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur
  `SELECT id FROM users WHERE login='admin'`
- `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers`
  et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`)
  exigent une session admin : 401 sans session, 403 non-admin
- `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants
  dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le
  provider par défaut du produit (commentaire `ponytail:` pour la fermeture)
- +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK
2026-10-01 07:53:06 -04:00
bruno 1f705ce512 fix: A19 terminé — plus aucun préfixe cookie-auth exempt du CSRF (v7.3.6)
FlowDeck CI / test (push) Successful in 20m10s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
- 46 appels non-GET équipés de `X-CSRF-Token` (expression cookie en ligne,
  portée indifférente) : agent_panel (9), settings (12), local_workspace (15),
  gitea_workspace (4), workspace (2), workspaces (5), library (2), welcome (2 en v7.3.5)
- 5 derniers préfixes sortis d'`EXCLUDED_PATHS` : /api/workspace (+/api/workspaces),
  /api/local-workspace, /api/settings, /api/gitea, /api/agent
- il ne reste exempté que : Bearer (/api/webhook, /api/v1, /api/v2, /scim/v2),
  callbacks /auth/*, pages publiques (/s/, /f/), /api/csrf-token et /api/frontend-error
- vérif : `node --check` des <script> des 39 templates → 0 échec (avant et après)
- tests : `anon_csrf()` sur les 5 tests d'anonymat devenus CSRF-first
- suite **1026/1026** · `ruff check app tests` OK
2026-10-01 07:41:23 -04:00
bruno cf76e00f12 docs(roadmap): A19 — 49 fetch restants (compte exact) au lieu de 51
FlowDeck CI / docker (push) Successful in 1m48s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m14s
2026-09-30 23:38:28 -04:00
bruno 0861f1fdbf fix: A19 (partiel) — CSRF réservé aux vrais cas d'exemption (v7.3.5)
- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes
  n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS`
  (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`,
  `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`,
  `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`)
- `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header
  (`adminFetch` prouve que `/api/admin` était déjà couvert)
- reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`),
  `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch
- tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de
  route du 403 middleware — 4 tests d'anonymat ajustés
- suite **1026/1026** · `ruff check app tests` OK
2026-09-30 23:38:03 -04:00
67 changed files with 3807 additions and 1107 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
DEFAULT_LANG=fr DEFAULT_LANG=fr
# ── Database ── # ── Database ──
# SQLite (default): sqlite:////data/flowdeck.db # SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck # schéma retombe silencieusement sur /data/flowdeck.db).
DATABASE_URL=sqlite:////data/flowdeck.db DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ── # ── Sync ──
+120
View File
@@ -1,5 +1,125 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.4.0 (2026-10-01) — Audit : A30, A37, A39, A40, A41
### Changed
- **A30** — `require_scope()` est enfin câblé : 69 sites stricts de `api_v2.py`
passent par la factory (Bearer + scope en un appel, contrôle manuel supprimé ;
les 4 variants `admin|is_admin` restent manuels, ce sont d'autres contrôles) ;
12 top-level morts supprimés (`unsync_block`, `find_referring`, `_b64url`,
`strip_markdown`, `format_number`, … — 0 référence app ET tests)
- **A37** — CORS : plus de `allow_origins/methods/headers = ["*"]` → origines
dérivées de `settings.app_base_url` + localhost/origines d'extension
(`allow_origin_regex`), méthodes et entêtes minutés, `allow_credentials=True`
explicite ; test `test_cors_no_star`
- **A40** — version d'assets à source unique : `{{ asset_version }}` (global
Jinja lu au boot depuis le fichier VERSION) ; les littéraux `?v=5.1.1`,
`?v=2.4.8`, `?v=6.0.0` de base.html éliminés ; `sw.js` n'existe plus (audit
obsolète) ; vendors gardent `?v=` = version de la lib (correct)
- **A41** — 91 règles CSS mortes purgées d'`app.css` : **-10 274 octets**
(121 618 → 111 344) — scan : classes définies dans app.css et absentes de
templates, JS, autres CSS et code Python
### Notes
- **A39 (htmx)** — décision « rien » : 32 attributs `hx-*` réels, conversion =
refonte du view-switching sans tests E2E ; à reconsidérer avec un test
automatisé du view-switch
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
la valeur par défaut (il signe les sessions)
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
(croissance mémoire bornée)
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
scheduler passés en `warning`
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
dupliqué retiré du ROADMAP
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
(format ISO naïf identique, zéro changement de comportement)
### Notes
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
l'alignement à 3.13 implique un rebuild d'image à valider
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
d'événements/webhooks et des écritures sont enfin visibles dans les logs
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
transaction au lieu de commiter une collection sans schéma
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
point d'entrée des connexions) ; le wrapper async + les 510 call sites
synchrones sur l'event loop restent à migrer
### Tests
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
### Fixed
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
`ping()` du serveur vers un `api_base` interne
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
### Fixed
- **A19 (fin)** — les 46 appels non-GET restants des 5 derniers préfixes
(`/api/agent`, `/api/settings`, `/api/local-workspace`, `/api/gitea`,
`/api/workspace` + `/api/workspaces`) reçoivent `X-CSRF-Token` (expression
cookie en ligne, portée indifférente fonction/Alpine/attribut) ; les 5
préfixes sortent d'`EXCLUDED_PATHS`
- Vérification syntaxe : les `<script>` des 39 templates passent `node --check`
(interpolations Jinja neutralisées) — 0 échec avant/après
- Tests : `anon_csrf()` là où le 403 CSRF masquait le 401 attendu, paire
CSRF sur le TestClient jetable de `test_sessions_listed_and_revocable`
- suite **1026/1026** · `ruff check app tests` OK — la liste CSRF ne contient
plus que du Bearer, des callbacks `/auth/*`, des pages publiques et de l'infra
## v7.3.5 (2026-09-30) — Audit sécurité : A19 (partiel) — CSRF réduit aux vrais cas
### Fixed
- **A19 (partiel)** — 12 préfixes sortis de `EXCLUDED_PATHS` après scan des
appels non-GET du front (tous envoient déjà `X-CSRF-Token`) : `/db/`,
`/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`,
`/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`,
`/api/github`, `/api/admin`, `/api/onboarding` — les 2 fetch de
`welcome.html` équipés du header
- La liste ne garde que Bearer/webhooks/callbacks/pages publiques + les 5
préfixes dont le front n'est pas encore équipé (`/api/workspace`,
`/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent`)
- Helper `anon_csrf()` en test (anonyme + CSRF valide → on mesure le 401 de la
route, pas le 403 du middleware) → suite **1026/1026**
## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures) ## v7.3.4 (2026-09-30) — Audit sécurité : A16 (ACL sur lectures)
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users. Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline > **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
## Quick Start ## Quick Start
+16 -20
View File
@@ -1049,10 +1049,6 @@ Détails livrés :
--- ---
## 🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note | | Feature | Fichiers | Note |
@@ -1135,42 +1131,42 @@ Quality DB views, Agent IA Palette → Realtime + E
- [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] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.* - [x] **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] **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] **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] **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] **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] **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**.*
- [ ] **A19 — Liste CSRF trop large (34 préfixes, match `startswith`)** : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.* - [x] **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**.* - [ ] **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**.* - [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.* - [x] **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] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.* - [x] **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**.* - [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*
### 🟡 P2 — Moyennes ### 🟡 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**.* - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.* - [ ] **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**.* - [ ] **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**.* - [ ] **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**.* - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **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**.* - [ ] **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**.* - [ ] **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**.* - [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.* - [x] **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**.* - [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.* - [x] **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**.* - [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
### 🟢 P3 — Basses / hygiène ### 🟢 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**.* - [ ] **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**.* - [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.* - [x] **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**.* - [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [ ] **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**.* - [ ] **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**.* - [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **reste** : `__CSRF_PLACEHOLDER__` (base.html, fenêtre de course JS) et le re-parse JSON de la palette par frappe : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker) ### ✅ Vérifié non-problème (ne pas re-checker)
@@ -1185,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. → 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 ).* *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.** → **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0.
+1 -1
View File
@@ -1 +1 @@
7.3.4 7.4.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.4 (audit sécurité A1–A24 + A16) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.4.0 (audit — A30/A37/A39/A40/A41) | **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` > **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 ## Avancement Global
-2
View File
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
return verifier, challenge return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes: def _b64url_decode(data: str) -> bytes:
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
@@ -31,7 +31,7 @@ class SessionManager:
""" """
payload = { payload = {
"user": user_data, "user": user_data,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
user_id = user_data.get("id") user_id = user_data.get("id")
if user_id: if user_id:
@@ -94,7 +94,7 @@ class SessionManager:
sid = SessionManager.session_id(cookie) if cookie else None sid = SessionManager.session_id(cookie) if cookie else None
payload = { payload = {
"user": user_data, "user": user_data,
"created_at": datetime.utcnow().isoformat(), "created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
user_id = user_data.get("id") user_id = user_data.get("id")
if user_id: if user_id:
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_touch_session")
# FastAPI dependency # FastAPI dependency
+3 -4
View File
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
github_oauth_client_id: str = "" github_oauth_client_id: str = ""
github_oauth_client_secret: str = "" github_oauth_client_secret: str = ""
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*), # OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub) # set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = "" oauth_redirect_uri: str = ""
@@ -129,7 +126,9 @@ class Settings(BaseSettings):
import re import re
if re.match(r'^[a-zA-Z]:', p): if re.match(r'^[a-zA-Z]:', p):
return Path(p) return Path(p)
return Path("/" + p) # A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
return Path("/" + p.lstrip("/"))
return Path("/data/flowdeck.db") return Path("/data/flowdeck.db")
+5
View File
@@ -837,6 +837,11 @@ def get_conn():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA foreign_keys=ON")
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
# l'event loop) reste à migrer module par module.
conn.execute("PRAGMA busy_timeout=5000")
try: try:
yield conn yield conn
finally: finally:
+58 -11
View File
@@ -71,6 +71,31 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _spawn(name: str, factory):
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
Loggue l'exception puis recrée la coroutine 10 s plus tard.
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
le bruit devient un problème.
"""
async def _guard():
while True:
try:
await factory()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
await asyncio.sleep(10)
else:
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
await asyncio.sleep(10)
return asyncio.create_task(_guard())
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
init_db() init_db()
@@ -81,6 +106,13 @@ async def lifespan(_app: FastAPI):
from app.db import get_conn from app.db import get_conn
from app.password_utils import hash_password from app.password_utils import hash_password
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
if settings.app_secret_key == "change-me-to-random":
raise RuntimeError(
"APP_SECRET_KEY non défini — générer une valeur : "
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
)
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au # 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. # premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
with get_conn() as conn: with get_conn() as conn:
@@ -99,41 +131,41 @@ async def lifespan(_app: FastAPI):
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler()) scheduler_task = _spawn("agent_scheduler", agent_scheduler)
# ── Automations (v5.1.0): cron trigger scheduler ── # ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler()) automation_task = _spawn("automation_scheduler", automation_scheduler)
# ── Backups (v5.2.0): automatic daily SQLite snapshot ── # ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler()) backup_task = _spawn("backup_scheduler", backup_scheduler)
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ── # ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler()) projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ── # ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler()) trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
# ── Reminders (v5.8.0): due-reminder scan for database rows ── # ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler()) reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ── # ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler from app.services.semantic_search import semantic_index_scheduler
semantic_task = asyncio.create_task(semantic_index_scheduler()) semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
# ── Calendar sync (v7.1.0): external calendars every 15 min ── # ── Calendar sync (v7.1.0): external calendars every 15 min ──
from app.services.calendar_sync import calendar_sync_scheduler from app.services.calendar_sync import calendar_sync_scheduler
calendar_task = asyncio.create_task(calendar_sync_scheduler()) calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
# ── Webhooks outbound (v6.4.0): retry failed deliveries ── # ── Webhooks outbound (v6.4.0): retry failed deliveries ──
from app.services.webhook_outbound import webhook_retry_scheduler from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None webhook_task = None
if settings.webhook_retry_enabled: if settings.webhook_retry_enabled:
webhook_task = asyncio.create_task(webhook_retry_scheduler()) webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port) logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try: try:
@@ -153,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.3.4", version="7.4.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -163,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)
app.add_middleware(ContentSecurityPolicyMiddleware) app.add_middleware(ContentSecurityPolicyMiddleware)
app.add_middleware(RateLimitMiddleware) app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # A37 : origines explicites (l'auth est un cookie de session ; le front est
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
allow_credentials=True,
)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(sso_router) app.include_router(sso_router)
+15 -1
View File
@@ -18,7 +18,21 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not # 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. # apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
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", "/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", "/s/", "/f/"} # 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): async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt # Webhook receiver, OAuth callback, and internal API are exempt
+56 -6
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting.""" """FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import time import time
from collections import defaultdict from collections import defaultdict
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Paths that should be rate-limited # Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = ( RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/", "/api/", "/board/api/", "/auth/",
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
# API workspace + collections (les endpoints mutants du legacy).
"/scim/v2/", "/workspace/", "/db/",
) )
# Pages publiques : seul le non-GET est plafonné (brute force de
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
# visiteurs d'un site publié qui partagent une IP.
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
# Paths exempt from rate limiting even under an API prefix # Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({ EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health", "/api/health",
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"/api/frontend-errors", "/api/frontend-errors",
}) })
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60): def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
super().__init__(app) super().__init__(app)
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0)) self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
self._last_prune = 0.0
self._max_keys = 5000
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
path = request.url.path path = request.url.path
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if not settings.rate_limit_enabled: if not settings.rate_limit_enabled:
return await call_next(request) return await call_next(request)
# Only rate-limit API routes # Only rate-limit API routes (+ non-GET sur les pages publiques)
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES): method = request.method.upper()
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
method not in ("GET", "HEAD", "OPTIONS")
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
)
if not limited:
return await call_next(request) return await call_next(request)
# Exempt health check and error capture # Exempt health check and error capture
if path in self.EXEMPT_PATHS: if path in self.EXEMPT_PATHS:
return await call_next(request) return await call_next(request)
ip = request.client.host if request.client else "unknown" limit = self.max_requests or settings.rate_limit_requests
ip = self._client_key(request)
now = time.time() now = time.time()
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
self._prune(now)
window_start, count = self._store[ip] window_start, count = self._store[ip]
if now - window_start > self.window_seconds: if now - window_start > self.window_seconds:
self._store[ip] = (now, 1) self._store[ip] = (now, 1)
return await call_next(request) return await call_next(request)
if count >= self.max_requests: if count >= limit:
return JSONResponse( return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"}, {"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
status_code=429, status_code=429,
) )
self._store[ip] = (window_start, count + 1) self._store[ip] = (window_start, count + 1)
return await call_next(request) return await call_next(request)
def _client_key(self, request: Request) -> str:
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
globales, pas seulement RFC1918) — un pair non-global n'est pas un
internaute, donc le XFF du proxy fait foi.
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
exposée directement), prendre la dernière adresse non privée de la
chaîne plutôt que la première.
"""
host = request.client.host if request.client else "unknown"
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
try:
direct = ipaddress.ip_address(host)
if direct.is_private or direct.is_loopback:
return fwd.split(",")[0].strip() or host
except ValueError:
pass # hôte non-IP (testserver…) → on garde la clé d'origine
return host
def _prune(self, now: float) -> None:
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
for k in expired:
del self._store[k]
self._last_prune = now
+1 -1
View File
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
try: try:
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL") conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
except Exception: except Exception:
pass logger.exception("_migration_v630_api_v2")
conn.execute( conn.execute(
"""CREATE TABLE IF NOT EXISTS api_audit_log ( """CREATE TABLE IF NOT EXISTS api_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -18
View File
@@ -1,10 +1,9 @@
"""FlowDeck — Pydantic request models for API validation.""" """FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations from __future__ import annotations
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext from app.middleware.security import ALLOWED_EXTENSIONS, _ext
# ── File Save ──────────────────────────────────────────────── # ── File Save ────────────────────────────────────────────────
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
error: str | None = None error: str | None = None
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel): class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue.""" """Request model for creating a Gitea issue."""
+44 -24
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
triggers = conn.execute( triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1" "SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall() ).fetchall()
now = datetime.utcnow() now = datetime.now(UTC).replace(tzinfo=None)
for trig in triggers: for trig in triggers:
last = trig["last_fired_at"] last = trig["last_fired_at"]
if last: if last:
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
logger.exception("Agent scheduler tick failed") logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int | None: async def _current_user_id(request: Request) -> int:
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
user = await get_current_user(request) user = await get_current_user(request)
if user and user.get("id"): if not user or not user.get("id"):
return user["id"] raise HTTPException(status_code=401, detail="Authentication required")
with get_conn() as conn: return user["id"]
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else None
async def _workspace_id(request: Request) -> int | None: async def _workspace_id(request: Request) -> int | None:
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
async def _current_admin(request: Request) -> dict: async def _current_admin(request: Request) -> dict:
"""Require an admin session. Falls back to the single admin row, matching """A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
the agent router's unauthenticated convention (single-user deployments).""" laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
`ping()` vers un `api_base` de son choix = SSRF)."""
user = await get_current_user(request) user = await get_current_user(request)
if user: if not user:
if not user.get("is_admin"): raise HTTPException(status_code=401, detail="Authentication required")
from app.db import get_conn as _gc if not user.get("is_admin"):
with _gc() as conn: from app.db import get_conn as _gc
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone() with _gc() as conn:
if not row or not row["is_admin"]: row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
raise HTTPException(status_code=403, detail="Accès administrateur requis") if not row or not row["is_admin"]:
return user raise HTTPException(status_code=403, detail="Accès administrateur requis")
with get_conn() as conn: return user
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return dict(row)
def _default_agent(conn, user_id: int) -> dict: def _default_agent(conn, user_id: int) -> dict:
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
return {"ok": False, "provider": provider, "error": str(exc)} return {"ok": False, "provider": provider, "error": str(exc)}
def _check_api_base(value: str) -> str:
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
settings `llm_allow_private=false`.
"""
url = (value or "").strip()
if not url:
return ""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
return url
@router.patch("/providers") @router.patch("/providers")
async def update_provider_config(request: Request): async def update_provider_config(request: Request):
await _current_admin(request) await _current_admin(request)
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
provider=provider or None, provider=provider or None,
model=(body.get("model") or "").strip() or None, model=(body.get("model") or "").strip() or None,
api_key=body.get("api_key"), api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None, api_base=_check_api_base(body.get("api_base") or "") or None,
clear_keys=(provider == "offline"), clear_keys=(provider == "offline"),
) )
llm = LLMClient() llm = LLMClient()
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
llm = LLMClient( llm = LLMClient(
provider=provider, provider=provider,
api_key=body.get("api_key"), api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None, api_base=_check_api_base(body.get("api_base") or "") or None,
) )
try: try:
resp = await llm.ping(model=(body.get("model") or "").strip() or None) resp = await llm.ping(model=(body.get("model") or "").strip() or None)
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -46,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
if not settings.rate_limit_enabled: if not settings.rate_limit_enabled:
return True return True
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
now = datetime.utcnow().timestamp() now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0)) window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60: if now - window_start > 60:
_rate_limit_store[ip] = (now, 1) _rate_limit_store[ip] = (now, 1)
@@ -67,12 +67,12 @@ async def health(request: Request):
conn.execute("SELECT 1") conn.execute("SELECT 1")
db_ok = True db_ok = True
except Exception: except Exception:
pass logger.exception("health")
try: try:
await gitea.get_user_repos(page=1, limit=1) await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True gitea_ok = True
except Exception: except Exception:
pass logger.exception("health")
return { return {
"status": "ok" if (db_ok and gitea_ok) else "degraded", "status": "ok" if (db_ok and gitea_ok) else "degraded",
+108 -250
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -573,4 +573,4 @@ def _log_login(user_id: int, request: Request):
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_log_login")
+9 -18
View File
@@ -695,7 +695,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
local_ws_id = row["id"] local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id)) workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception: except Exception:
pass logger.exception("_sidebar_data")
elif ws_cookie and user: elif ws_cookie and user:
try: try:
wsi = int(ws_cookie) wsi = int(ws_cookie)
@@ -781,7 +781,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
elif t["provider"] == "github": elif t["provider"] == "github":
github_linked = True github_linked = True
except Exception: except Exception:
pass logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
@@ -850,15 +850,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
"""Return groups from Gitea labels/milestones or fallback to defaults."""
try:
labels = json.loads(
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
) if False else []
except Exception:
labels = []
return labels if labels else ["Design", "Engineering", "No Team"]
async def asyncio_get_labels(owner: str, repo: str): async def asyncio_get_labels(owner: str, repo: str):
@@ -982,7 +973,7 @@ async def add_favorite(request: Request, page_id: int):
try: try:
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid}) await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_favorite")
return {"status": "added", "page_id": page_id} return {"status": "added", "page_id": page_id}
@@ -997,7 +988,7 @@ async def remove_favorite(request: Request, page_id: int):
try: try:
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}) await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id} return {"status": "removed", "page_id": page_id}
# ═══════════ Share API ═══════════ # ═══════════ Share API ═══════════
@@ -1032,7 +1023,7 @@ async def publish_page(request: Request, page_id: int):
try: try:
await fire_event("page.published", {"page_id": page_id, "slug": slug}) await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception: except Exception:
pass logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""} return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@@ -1048,7 +1039,7 @@ async def unpublish_page(request: Request, page_id: int):
try: try:
await fire_event("page.unpublished", {"page_id": page_id}) await fire_event("page.unpublished", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("unpublish_page")
return {"is_published": False} return {"is_published": False}
@@ -1069,7 +1060,7 @@ async def restore_page(request: Request, page_id: int):
try: try:
await fire_event("page.restored", {"page_id": page_id}) await fire_event("page.restored", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("restore_page")
return {"status": "ok", "restored": page_id} return {"status": "ok", "restored": page_id}
@@ -1732,7 +1723,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin" ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}: if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format") raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}" folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}" final = f"{stamp}_{name}"
@@ -2040,7 +2031,7 @@ async def delete_page(request: Request, page_id: int):
if not row: if not row:
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
import datetime import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,)) conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit() conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}) await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
return {"status": "ok", "deleted": page_id, "title": row["title"]} return {"status": "ok", "deleted": page_id, "title": row["title"]}
+3 -3
View File
@@ -129,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
if mentioned_ids: if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}) await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
except Exception: except Exception:
pass logger.exception("add_comment")
return {"id": comment_id, "status": "created"} return {"id": comment_id, "status": "created"}
@@ -159,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
try: try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}) await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
except Exception: except Exception:
pass logger.exception("notify_page_mentions")
return {"mentioned": mentioned} return {"mentioned": mentioned}
@@ -190,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
try: try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception: except Exception:
pass logger.exception("update_comment")
return {"id": comment_id, "status": "updated"} return {"id": comment_id, "status": "updated"}
+12 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -52,7 +53,7 @@ def _get_user_or_redirect(request: Request):
if count == 0: if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True} return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception: except Exception:
pass logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302) return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user return user
@@ -88,7 +89,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
avatar_url = row["avatar_url"] or "" avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A" avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception: except Exception:
pass logger.exception("_sidebar_data")
recent_pages = [] recent_pages = []
for repo in repos[:10]: for repo in repos[:10]:
@@ -179,7 +180,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
elif t["provider"] == "github": elif t["provider"] == "github":
github_linked = True github_linked = True
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror # Get local workspace ID for Gitea workspace mirror
local_ws_id = 0 local_ws_id = 0
@@ -193,7 +194,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
if row: if row:
local_ws_id = row["id"] local_ws_id = row["id"]
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active) # Private pages for mirror workspace (when Gitea remote active)
private_pages = [] private_pages = []
@@ -206,7 +207,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
).fetchall() ).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows] private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception: except Exception:
pass logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi) # Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = [] shared_made_pages = []
@@ -771,7 +772,7 @@ async def dashboard(
template = env.get_template("landing.html") template = env.get_template("landing.html")
return template.render() return template.render()
except Exception: except Exception:
pass logger.exception("dashboard")
from app.templating import ENV from app.templating import ENV
env = ENV env = ENV
template = env.get_template("landing.html") template = env.get_template("landing.html")
@@ -788,7 +789,7 @@ async def dashboard(
).fetchone() ).fetchone()
has_gitea = bool(tok) has_gitea = bool(tok)
except Exception: except Exception:
pass logger.exception("dashboard")
if not has_gitea: if not has_gitea:
# Check if user has any workspace # Check if user has any workspace
@@ -801,7 +802,7 @@ async def dashboard(
# v5.2.0: first-launch → onboarding wizard # v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302) return RedirectResponse("/welcome", status_code=302)
except Exception: except Exception:
pass logger.exception("dashboard")
return RedirectResponse("/local-workspace", status_code=302) return RedirectResponse("/local-workspace", status_code=302)
# ── Gitea user → full dashboard ── # ── Gitea user → full dashboard ──
@@ -942,7 +943,7 @@ async def list_workspace_projects(request: Request):
"forge": "gitea", "forge": "gitea",
}) })
except Exception: except Exception:
pass logger.exception("list_workspace_projects")
return {"builtin": builtin, "gitea": gitea_repos, "github": []} return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@@ -1485,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL", "UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), item_id), (datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
) )
conn.commit() conn.commit()
return {"status": "ok"} return {"status": "ok"}
@@ -2097,7 +2098,7 @@ async def add_item_tag(request: Request, item_id: int):
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("add_item_tag")
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}} return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
+1 -1
View File
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
if ext not in _IMAGE_EXTS: if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format") raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request) ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f") stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}" folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}" final = f"emoji_{stamp}_{safe}"
-8
View File
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
return client return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs") @router.get("/orgs")
async def list_orgs(request: Request): async def list_orgs(request: Request):
"""List organizations the user belongs to.""" """List organizations the user belongs to."""
+1 -1
View File
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
try: try:
await websocket.close(code=4401) await websocket.close(code=4401)
except Exception: except Exception:
pass logger.exception("ws_page")
return return
conn = await manager.connect(websocket, page_id, user) conn = await manager.connect(websocket, page_id, user)
+1 -1
View File
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
try: try:
request.session.clear() request.session.clear()
except Exception: except Exception:
pass logger.exception("revoke_session")
return {"status": "revoked"} return {"status": "revoked"}
+6 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
import re import re
import unicodedata import unicodedata
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
try: try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}) await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
except Exception: except Exception:
pass logger.exception("share_page")
return { return {
"id": share_id, "id": share_id,
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
try: try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug}) await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception: except Exception:
pass logger.exception("publish_page")
return { return {
"page_id": page_id, "page_id": page_id,
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
try: try:
await _fire_event("page.unpublished", {"page_id": page_id}) await _fire_event("page.unpublished", {"page_id": page_id})
except Exception: except Exception:
pass logger.exception("unpublish_page")
return { return {
"page_id": page_id, "page_id": page_id,
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
DO UPDATE SET workspace=excluded.workspace, DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type, source_type=excluded.source_type,
accessed_at=excluded.accessed_at""", accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()), (user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
) )
conn.commit() conn.commit()
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
"status": "tracked", "status": "tracked",
"user_id": user["id"], "user_id": user["id"],
"page_id": page_id, "page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(), "accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
} }
+10 -5
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib import hashlib
import html import html
import json import json
import logging
import re import re
import secrets import secrets
import time import time
@@ -35,6 +36,8 @@ from app.services.api_v2_helpers import (
row_to_dict, row_to_dict,
) )
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sites"]) router = APIRouter(tags=["sites"])
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$") _SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
@@ -160,7 +163,7 @@ def _render_page_html(page: dict) -> str:
from app.services.synced_blocks import resolve_synced_block from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks) blocks = resolve_synced_block(blocks)
except Exception: except Exception:
pass logger.exception("_render_page_html")
titles: dict = {} titles: dict = {}
try: try:
from app.db import get_conn as _gc from app.db import get_conn as _gc
@@ -223,7 +226,7 @@ def _track_view(site_id: int) -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("_track_view")
def _form_config(conn, collection_id: int) -> dict: def _form_config(conn, collection_id: int) -> dict:
@@ -589,12 +592,14 @@ async def public_site_auth(request: Request, slug: str):
try: try:
password = (await request.json()).get("password", "") password = (await request.json()).get("password", "")
except Exception: except Exception:
logger.exception("public_site_auth")
password = "" password = ""
else: else:
try: try:
form = await request.form() form = await request.form()
password = form.get("password", "") password = form.get("password", "")
except Exception: except Exception:
logger.exception("public_site_auth")
password = "" password = ""
if not verify_password(password or "", site["password_hash"] or ""): if not verify_password(password or "", site["password_hash"] or ""):
raise HTTPException(401, "Wrong password") raise HTTPException(401, "Wrong password")
@@ -781,7 +786,7 @@ async def submit_form(request: Request, token: str):
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception:
pass logger.exception("submit_form")
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200] title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
cur = conn.execute( cur = conn.execute(
"""INSERT INTO collection_pages (collection_id, title, property_values_json) """INSERT INTO collection_pages (collection_id, title, property_values_json)
@@ -808,12 +813,12 @@ async def submit_form(request: Request, token: str):
except Exception: except Exception:
continue continue
except Exception: except Exception:
pass logger.exception("submit_form")
try: try:
from app.services.automations import fire_event as _fire from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id}) await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception: except Exception:
pass logger.exception("submit_form")
if "application/json" in ctype: if "application/json" in ctype:
return {"status": "ok", "row_id": row_id, return {"status": "ok", "row_id": row_id,
"message": cfg.get("success_message") or "Merci !"} "message": cfg.get("success_message") or "Merci !"}
+3 -3
View File
@@ -179,7 +179,7 @@ async def clip_page(request: Request):
if isinstance(_imgs, list) and _imgs: if isinstance(_imgs, list) and _imgs:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or "" _img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception: except Exception:
pass logger.exception("clip_page")
clip_data = { clip_data = {
"url": url, "url": url,
"title": title[:200], "title": title[:200],
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
try: try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"]) log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception: except Exception:
pass logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"} return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@@ -249,7 +249,7 @@ async def extensions_page(request: Request):
devices = list_devices(user["id"]) devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices) clips = sum(d.get("clips_count", 0) for d in devices)
except Exception: except Exception:
pass logger.exception("extensions_page")
content_html = f""" content_html = f"""
<style> <style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}} .ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
+5 -5
View File
@@ -160,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
try: try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}) await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"} return {"id": cur.lastrowid, "status": "created"}
@@ -180,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
try: try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception: except Exception:
pass logger.exception("update_comment")
return {"status": "updated"} return {"status": "updated"}
@@ -246,7 +246,7 @@ async def add_favorite(request: Request):
try: try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}) await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
except Exception: except Exception:
pass logger.exception("add_favorite")
return {"status": "favorited"} return {"status": "favorited"}
@@ -493,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
try: try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}) await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
except Exception: except Exception:
pass logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"} return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -523,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
try: try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}) await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
except Exception: except Exception:
pass logger.exception("update_sprint")
return {"id": sid, "status": "updated"} return {"id": sid, "status": "updated"}
+13 -6
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
from app.config import settings from app.config import settings
from app.db import get_conn from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ────────────────────────────────────────────────────────────── # ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None: def to_iso8601(value: str | None) -> str | None:
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
try: try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k]) d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception: except Exception:
pass logger.exception("row_to_dict")
return d return d
# ── Pagination ──────────────────────────────────────────────────────────── # ── Pagination ────────────────────────────────────────────────────────────
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
if dt.timestamp() < time.time(): if dt.timestamp() < time.time():
return None return None
except Exception: except Exception:
pass logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone() u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u: if u:
d = dict(u) d = dict(u)
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)) conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("resolve_bearer_token")
return d return d
# 2) extension_devices # 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone() row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
@@ -211,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
return user return user
def require_scope(required: str): def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict: def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization) user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read" # Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required): if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}") raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user return user
@@ -257,7 +264,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("audit_log")
# ── Rate limit per token (in-memory) ───────────────────────────────────── # ── Rate limit per token (in-memory) ─────────────────────────────────────
@@ -307,4 +314,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("store_idempotency")
+3 -3
View File
@@ -24,7 +24,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx import httpx
@@ -420,7 +420,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
expr = (expression or "").strip().lower() expr = (expression or "").strip().lower()
if not expr: if not expr:
return False return False
now = now or datetime.utcnow() now = now or datetime.now(UTC).replace(tzinfo=None)
minute = now.minute minute = now.minute
fields = expr.split() fields = expr.split()
@@ -488,7 +488,7 @@ async def automation_scheduler():
from app.services.workers import run_due_workers from app.services.workers import run_due_workers
await run_due_workers() await run_due_workers()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("worker cron iteration failed") logger.warning("worker cron iteration failed")
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed") logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60) await asyncio.sleep(60)
+1 -1
View File
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
with sqlite3.connect(str(db_path)) as conn: with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception: except Exception:
pass logger.exception("backup_db")
dest_dir = _backup_dir() dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db" filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+1 -1
View File
@@ -466,7 +466,7 @@ async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
except Exception as exc: # noqa: BLE001 — one link must not kill the loop except Exception as exc: # noqa: BLE001 — one link must not kill the loop
logger.debug("calendar sync link %s failed: %s", link_id, exc) logger.debug("calendar sync link %s failed: %s", link_id, exc)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug("calendar_sync_scheduler: %s", exc) logger.warning("calendar_sync_scheduler: %s", exc)
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
+1 -1
View File
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
if langs: if langs:
repo_info["language"] = max(langs, key=langs.get) repo_info["language"] = max(langs, key=langs.get)
except Exception: except Exception:
pass logger.exception("get_repo_info")
self._set_cache(cache_key, repo_info) self._set_cache(cache_key, repo_info)
return repo_info return repo_info
-6
View File
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
return [str(value)] return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
-30
View File
@@ -247,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
} }
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
return None
def apply_auto_properties( def apply_auto_properties(
@@ -291,28 +284,5 @@ def apply_auto_properties(
return values return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+1 -1
View File
@@ -349,7 +349,7 @@ class RealtimeManager:
try: try:
await conn.ws.close(code=4413) await conn.ws.close(code=4413)
except Exception: except Exception:
pass logger.exception("_evict_slow")
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None): async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket.""" """Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
-3
View File
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
return dt.datetime.now(dt.UTC) return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None): def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
+1 -1
View File
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
) )
conn.commit() conn.commit()
except Exception: except Exception:
pass logger.exception("purge_stale_requests")
-11
View File
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
# ── Unsync: convert synced block to independent copy ────────────── # ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
+2 -1
View File
@@ -17,6 +17,7 @@ import json
import logging import logging
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC
from typing import Any from typing import Any
from app.db import get_conn from app.db import get_conn
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
return ToolResult(status="error", tool=self.name, return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable") message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid)) (datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
conn.commit() conn.commit()
return ToolResult( return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid, status="success", tool=self.name, target_type="document", target_id=pid,
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from app.db import get_conn from app.db import get_conn
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
Returns a summary of what was purged. Returns a summary of what was purged.
""" """
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
purged: list[int] = [] purged: list[int] = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
-13
View File
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False} return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
def verify_device_token(device_id: str, token: str) -> dict | None:
"""Verify a device token, returns device row or None."""
thash = _hash_token(token)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
(device_id, thash),
).fetchone()
if row:
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
return dict(row)
return None
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str): def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
-5
View File
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
return s return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+8 -8
View File
@@ -555,7 +555,7 @@
var payload = {prompt: message}; var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000); if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', { 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) body: JSON.stringify(payload)
}).then(function(resp){ }).then(function(resp){
return resp.json(); return resp.json();
@@ -701,7 +701,7 @@
if(self.llmModel) payload.model = self.llmModel; if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', { 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){ }).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; }); self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){ if(d && d.ok && d.text){
@@ -824,7 +824,7 @@
installGallerySkill(slug, icon, name){ installGallerySkill(slug, icon, name){
var self = this; var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', { 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){ }).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; }); return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){ }).then(function(res){
@@ -988,7 +988,7 @@
var body = {title:'Nouvelle conversation'}; var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider; if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel; 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){ .then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()}; var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv; self.conversations.unshift(conv); self.currentConv = conv;
@@ -1005,7 +1005,7 @@
if(self.llmProvider) body.provider = self.llmProvider; if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel; if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', { 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){ }).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()}; var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv; self.conversations.unshift(conv); self.currentConv = conv;
@@ -1027,7 +1027,7 @@
if(self.llmProvider) body.provider = self.llmProvider; if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel; if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, { 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(){}); }).catch(function(){});
}, },
@@ -1832,7 +1832,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id); if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', { fetch('/api/agent/feedback', {
method: 'POST', 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) body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){ }).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; } if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1930,7 +1930,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', { return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST', 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) body: JSON.stringify(payload)
}).then(function(resp){ }).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); } if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+5 -5
View File
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1"> <link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0"> <link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0"> <link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11"> <link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style> <style>
/* ── Mobile responsive (v4.0.2) ── */ /* ── Mobile responsive (v4.0.2) ── */
@@ -2129,7 +2129,7 @@
} }
}); });
</script> </script>
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script> <script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style> <style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;} .flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2377,7 +2377,7 @@
</style> </style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #} {# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script> <script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false"> <script data-cfasync="false">
(function() { (function() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
+4 -4
View File
@@ -135,7 +135,7 @@ document.addEventListener('alpine:init', () => {
var newPath = oldPath.replace(/[^/]+$/, newName.trim()); var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT', 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 }) body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); }) }).then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
@@ -150,7 +150,7 @@ document.addEventListener('alpine:init', () => {
if (!item) return; if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return; if (!confirm('Delete ' + item.name + '?')) return;
var self = this; 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' method: 'DELETE'
}).then(function(r){ }).then(function(r){
if (r.ok) { self.refreshTree(); } if (r.ok) { self.refreshTree(); }
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
if (!path) return; if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return; if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || ''; 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', method: 'DELETE',
}).then(function(r) { }).then(function(r) {
if (r.ok) self.refreshTree(); if (r.ok) self.refreshTree();
@@ -387,7 +387,7 @@ document.addEventListener('alpine:init', () => {
if (!this.filePath) return; if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return; if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try { 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', method: 'DELETE',
}); });
if (r.ok) { if (r.ok) {
+2 -2
View File
@@ -1198,7 +1198,7 @@ function libraryPage() {
var item = store && store.node; var item = store && store.node;
if (!item) return; if (!item) return;
var self = this; 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) { .then(function(r) {
if (!r.ok) return; if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; }); item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
@@ -1218,7 +1218,7 @@ function libraryPage() {
try { try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', { var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST', 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}) body: JSON.stringify({name: tagName, color: color})
}); });
if (r.ok) { if (r.ok) {
+15 -15
View File
@@ -781,7 +781,7 @@ window._wsInitData = (function() {
var self = this; var self = this;
// Soft-delete all selected items // Soft-delete all selected items
for (var i=0; i<ids.length; i++) { 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.clearSelection();
this._reloadAfterAction(); this._reloadAfterAction();
@@ -1207,7 +1207,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774'; color = color || (store && store.newTagColor) || '#787774';
try { try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', { 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}) body: JSON.stringify({name: tagName, color: color})
}); });
if (r.ok) { if (r.ok) {
@@ -1344,7 +1344,7 @@ window._wsInitData = (function() {
if (!newName) return; if (!newName) return;
try { try {
var r = await fetch('/api/local-workspace/items/' + id, { 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}) body: JSON.stringify({name: newName})
}); });
if (r.ok) { if (r.ok) {
@@ -1524,7 +1524,7 @@ window._wsInitData = (function() {
this.renamingId = null; this.renamingId = null;
if (!n || n === node.name) return; if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, { 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}) body: JSON.stringify({name: n})
}); });
if (r.ok) { if (r.ok) {
@@ -1579,7 +1579,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null; var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) { this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', { 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}) body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); }); }).then(function(r) { if (r.ok) self._reloadAfterAction(); });
}); });
@@ -1590,7 +1590,7 @@ window._wsInitData = (function() {
// ── Duplicate ── // ── Duplicate ──
async duplicateItem(node) { async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', { 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', body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null}) parent_id: this.currentFolder > 0 ? this.currentFolder : null})
}); });
@@ -1618,7 +1618,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"'; this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true; this.undoVisible = true;
// Delete via API // 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; } if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction) // Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction(); this._reloadAfterAction();
@@ -1643,7 +1643,7 @@ window._wsInitData = (function() {
self._reloadAfterAction(); self._reloadAfterAction();
return; 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++; }) .then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); }); .finally(function() { restoreOne(i + 1); });
} }
@@ -1941,7 +1941,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id; if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', { var r = await fetch('/api/local-workspace/items', {
method:'POST', 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) body:JSON.stringify(body)
}); });
if (r.ok) { if (r.ok) {
@@ -2025,7 +2025,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return; if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT', 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}) body:JSON.stringify({name:n})
}); });
if (r.ok) { this._reloadAfterAction(); } if (r.ok) { this._reloadAfterAction(); }
@@ -2038,7 +2038,7 @@ window._wsInitData = (function() {
async doDelete() { async doDelete() {
if (!this.target) return; 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(); } if (r.ok) { this._reloadAfterAction(); }
}, },
@@ -2231,7 +2231,7 @@ window._wsInitData = (function() {
try { try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', { var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT', 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) body: JSON.stringify(body)
}); });
if (r.ok) { if (r.ok) {
@@ -2327,7 +2327,7 @@ window._wsInitData = (function() {
try { try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', { var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST', 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}) body: JSON.stringify({name: tagName})
}); });
if (r.ok) { if (r.ok) {
@@ -2346,7 +2346,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) { async removeTag(itemId, tagId) {
try { 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' method: 'DELETE'
}); });
if (r.ok) { if (r.ok) {
@@ -2492,7 +2492,7 @@ window._wsInitData = (function() {
try { try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', { await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT', 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 }) body: JSON.stringify({ parent_id: parentId || null })
}); });
} catch(e) {} } catch(e) {}
+12 -12
View File
@@ -1337,7 +1337,7 @@ function settingsInit() {
var n = this.newTagName.trim(); var n = this.newTagName.trim();
if (!n) return; if (!n) return;
var r = await fetch('/api/settings/tags', { 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}) body: JSON.stringify({name: n, color: this.newTagColor})
}); });
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); } if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
@@ -1345,7 +1345,7 @@ function settingsInit() {
async updateTagColor(id, color) { async updateTagColor(id, color) {
await fetch('/api/settings/tags/' + id, { 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}) body: JSON.stringify({color: color})
}); });
await this.loadTags(); await this.loadTags();
@@ -1353,7 +1353,7 @@ function settingsInit() {
async deleteTag(id) { async deleteTag(id) {
if (!confirm('Delete this tag?')) return; 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(); await this.loadTags();
}, },
@@ -1375,7 +1375,7 @@ function settingsInit() {
this.renamingTag = null; return; this.renamingTag = null; return;
} }
await fetch('/api/settings/tags/' + tag.id, { 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}) body: JSON.stringify({name: newName})
}); });
this.renamingTag = null; this.renamingTag = null;
@@ -1648,7 +1648,7 @@ function settingsInit() {
try { try {
var r = await fetch('/api/agent/keys/' + id + '/models', { var r = await fetch('/api/agent/keys/' + id + '/models', {
method: 'POST', 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}) body: JSON.stringify({api_key: f.api_key, api_base: f.api_base})
}); });
var d = await r.json(); var d = await r.json();
@@ -1677,7 +1677,7 @@ function settingsInit() {
if (f.models && f.models.length) body.models = f.models; if (f.models && f.models.length) body.models = f.models;
var r = await fetch('/api/agent/keys/' + id, { var r = await fetch('/api/agent/keys/' + id, {
method: 'PUT', 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) body: JSON.stringify(body)
}); });
var d = await r.json(); var d = await r.json();
@@ -1713,7 +1713,7 @@ function settingsInit() {
if (f.api_key) body.api_key = f.api_key; if (f.api_key) body.api_key = f.api_key;
var r = await fetch('/api/agent/keys/' + id + '/test', { var r = await fetch('/api/agent/keys/' + id + '/test', {
method: 'POST', 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) body: JSON.stringify(body)
}); });
var d = await r.json(); var d = await r.json();
@@ -1739,7 +1739,7 @@ function settingsInit() {
var f = this.keyForm(id); var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false; f.deleting = true; f.msg = ''; f.ok = false;
try { 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(); var d = await r.json();
if (r.ok) { if (r.ok) {
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = ''; f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
@@ -1899,7 +1899,7 @@ function settingsInit() {
if (!file) return; if (!file) return;
var form = new FormData(); var form = new FormData();
form.append('file', file); 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) { if (r.ok) {
var d = await r.json(); var d = await r.json();
this.avatarUrl = d.avatar_url + '?t=' + Date.now(); this.avatarUrl = d.avatar_url + '?t=' + Date.now();
@@ -1910,7 +1910,7 @@ function settingsInit() {
async selectAvatarColor(color) { async selectAvatarColor(color) {
this.avatarColor = color; this.avatarColor = color;
var r = await fetch('/api/settings/avatar-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}) body: JSON.stringify({color: color})
}); });
if (r.ok) { this.avatarUrl = ''; } if (r.ok) { this.avatarUrl = ''; }
@@ -2055,7 +2055,7 @@ function settingsInit() {
// ── v5.2.0 API tokens ── // ── v5.2.0 API tokens ──
async loadApiTokens() { async loadApiTokens() {
try { 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(); var d = await r.json();
this.apiTokens = d.tokens || []; this.apiTokens = d.tokens || [];
} catch(e) { this.apiTokens = []; } } catch(e) { this.apiTokens = []; }
@@ -2065,7 +2065,7 @@ function settingsInit() {
if (!name) return; if (!name) return;
try { try {
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin', 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(); var d = await r.json();
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; } if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
this.newToken = d; this.newToken = d;
+2 -2
View File
@@ -155,7 +155,7 @@ function onboarding() {
async createWorkspace() { async createWorkspace() {
this.saving = true; this.saving = true;
try { 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(); const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; } if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.workspaceId = d.id; this.workspaceId = d.id;
@@ -172,7 +172,7 @@ function onboarding() {
async createProject() { async createProject() {
this.saving = true; this.saving = true;
try { 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(); const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; } if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.toast('Projet créé 🎉'); this.toast('Projet créé 🎉');
+1 -1
View File
@@ -174,7 +174,7 @@ function workspacePage() {
if (!this.newProjectName.trim()) return; if (!this.newProjectName.trim()) return;
const r = await fetch('/api/workspace/projects', { const r = await fetch('/api/workspace/projects', {
method: 'POST', 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()}) body: JSON.stringify({name: this.newProjectName.trim()})
}); });
if (r.ok) { if (r.ok) {
+4 -4
View File
@@ -214,7 +214,7 @@ function workspacesPage() {
}, },
async selectLocal(ws) { 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'; window.location = '/local-workspace';
}, },
@@ -222,7 +222,7 @@ function workspacesPage() {
if (!this.wsName.trim()) return; if (!this.wsName.trim()) return;
await fetch('/api/workspaces', { await fetch('/api/workspaces', {
method:'POST', 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()}) body:JSON.stringify({name:this.wsName.trim()})
}); });
this.wsName = ''; this.wsName = '';
@@ -240,7 +240,7 @@ function workspacesPage() {
if (!this.wsName.trim()||!this.renameTarget) return; if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, { await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT', 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()}) body:JSON.stringify({name:this.wsName.trim()})
}); });
this.wsName = ''; this.wsName = '';
@@ -251,7 +251,7 @@ function workspacesPage() {
async deleteWs(ws) { async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return; 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(); await this.load();
}, },
+14
View File
@@ -6,9 +6,23 @@ les `|safe` du codebase étaient des no-op.
""" """
from __future__ import annotations from __future__ import annotations
import pathlib
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
ENV = Environment( ENV = Environment(
loader=FileSystemLoader("app/templates"), loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]), autoescape=select_autoescape(["html"]),
) )
# A40 : version des assets statiques première main, lue une seule fois (le
# fichier VERSION fait foi ; repli "dev" si l'image ne le contient pas).
try:
ASSET_VERSION = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text(
encoding="utf-8"
).strip()
except OSError: # pragma: no cover
ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION
+1 -1
View File
@@ -772,7 +772,7 @@ EVENTS = [
6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements. 6. ⚠️ **Webhooks v2** : CRUD abonnements + `/test` + `/deliveries` livrés. **Reporté** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 événements.
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin. 7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE). 8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins). 9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version` = VERSION courante).
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin. 10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`. 11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
+3092 -46
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -5,12 +5,8 @@ jinja2==3.1.*
python-multipart==0.0.* python-multipart==0.0.*
pydantic==2.10.* pydantic==2.10.*
pydantic-settings==2.7.* pydantic-settings==2.7.*
aiosqlite==0.20.*
loguru==0.7.*
python-dotenv==1.0.* python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.* itsdangerous==2.2.*
slowapi==0.1.*
weasyprint==69.0 weasyprint==69.0
xhtml2pdf==0.2.* xhtml2pdf==0.2.*
openpyxl==3.1.* openpyxl==3.1.*
-488
View File
@@ -390,28 +390,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
.um-version { font-size: 11px; color: var(--text-dim); padding: 4px 14px 2px; } .um-version { font-size: 11px; color: var(--text-dim); padding: 4px 14px 2px; }
.um-section-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; padding: 4px 14px 2px; } .um-section-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; padding: 4px 14px 2px; }
.sidebar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.sidebar-action-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 14px;
transition: background var(--transition);
}
.sidebar-action-btn:hover {
background: var(--bg-hover);
}
/* Navigation items */ /* Navigation items */
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
@@ -421,27 +399,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
margin-bottom: var(--space-xs); margin-bottom: var(--space-xs);
} }
.sidebar-nav-item {
flex: 1;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 8px;
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
transition: background var(--transition);
white-space: nowrap;
}
.sidebar-nav-item:hover {
background: var(--bg-hover);
}
.sidebar-nav-item.active {
background: var(--bg-active);
}
.sidebar-nav-item .nav-icon { .sidebar-nav-item .nav-icon {
font-size: 16px; font-size: 16px;
width: 20px; width: 20px;
@@ -877,7 +834,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
} }
.nav-icon-inline { margin-right: 6px; vertical-align: middle; display: inline-flex; align-items: center; } .nav-icon-inline { margin-right: 6px; vertical-align: middle; display: inline-flex; align-items: center; }
.nav-icon-inline svg { vertical-align: middle; } .nav-icon-inline svg { vertical-align: middle; }
.empty-state-icon { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
.sidebar-item:active .page-icon { .sidebar-item:active .page-icon {
cursor: grabbing; cursor: grabbing;
@@ -967,60 +923,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
} }
.sidebar-invite {
background: var(--bg-hover);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-sm);
position: relative;
}
.sidebar-invite-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.sidebar-invite-desc {
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-invite-close {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 12px;
}
.sidebar-new-chat {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--space-md);
background: var(--bg-hover);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
width: 100%;
transition: background var(--transition);
}
.sidebar-new-chat:hover {
background: var(--bg-tertiary);
}
.sidebar-new-chat .shortcut { .sidebar-new-chat .shortcut {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -1137,35 +1039,10 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
flex-shrink: 0; flex-shrink: 0;
} }
.topbar-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
min-width: 0;
}
.topbar-page-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 14px;
color: var(--text-primary);
}
.topbar-page-title .page-icon { .topbar-page-title .page-icon {
font-size: 16px; font-size: 16px;
} }
.topbar-page-status {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.topbar-right { .topbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1201,14 +1078,6 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
background: var(--bg-hover); background: var(--bg-hover);
} }
.topbar-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--text-secondary);
}
/* ── Unified Header Breadcrumb ── */ /* ── Unified Header Breadcrumb ── */
.header-breadcrumb { .header-breadcrumb {
display: flex; display: flex;
@@ -1406,16 +1275,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover); background: var(--bg-hover);
} }
.option-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.topbar-breadcrumb a:hover {
color: var(--text-primary);
}
/* Content area */ /* Content area */
.content-area { .content-area {
flex: 1; flex: 1;
@@ -1424,11 +1283,6 @@ button.breadcrumb-current { max-width: 260px; }
padding: 0; padding: 0;
} }
/* Page header */
.page-header {
padding: var(--space-xl) var(--space-xl) var(--space-md);
}
.page-cover { .page-cover {
width: 100%; width: 100%;
height: 160px; height: 160px;
@@ -1755,22 +1609,6 @@ button.breadcrumb-current { max-width: 260px; }
font-weight: 500; font-weight: 500;
} }
/* Card colors by status */
.card-status-todo {
background: var(--bg-secondary);
border-left: 3px solid var(--gray);
}
.card-status-progress {
background: var(--blue-bg);
border-left: 3px solid var(--blue);
}
.card-status-complete {
background: var(--green-bg);
border-left: 3px solid var(--green);
}
/* New card button */ /* New card button */
.kanban-new-card { .kanban-new-card {
display: flex; display: flex;
@@ -2035,31 +1873,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--red); color: var(--red);
} }
/* Panel dropdown helpers */
.dropdown-search {
padding: var(--space-sm);
}
.dropdown-search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px var(--space-sm);
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.dropdown-search input:focus {
border-color: var(--blue);
}
.dropdown-option-list {
max-height: 240px;
overflow-y: auto;
}
.dropdown-option { .dropdown-option {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2102,26 +1915,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%; border-radius: 50%;
} }
.dropdown-footer {
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border);
}
.dropdown-footer button {
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.dropdown-footer button:hover {
color: var(--text-primary);
}
/* Sort panel */
.sort-panel {
position: relative;
}
.sort-list { .sort-list {
padding: var(--space-xs) 0; padding: var(--space-xs) 0;
} }
@@ -2228,32 +2021,14 @@ button.breadcrumb-current { max-width: 260px; }
UTILITIES UTILITIES
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
.flex { display: flex; } .flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.text-dim { color: var(--text-secondary); } .text-dim { color: var(--text-secondary); }
.text-xs { font-size: 12px; } .text-xs { font-size: 12px; }
.text-sm { font-size: 13px; } .text-sm { font-size: 13px; }
.font-semibold { font-weight: 600; }
/* Library table */
.library-table-header {
display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; font-weight: 600; color: var(--text-dim);
}
.library-table-row {
display: flex; align-items: center; padding: 8px 12px; text-decoration: none;
color: var(--text-primary); border-radius: 6px; transition: background 0.15s;
}
.library-table-row:hover { background: var(--bg-hover); }
.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; } .lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; }
.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); } .lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); } .lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); }
.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); } .lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); } .lib-empty { text-align: center; padding: 48px; color: var(--text-dim); }
.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); } .lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); }
.lib-empty p { font-size: 13px; } .lib-empty p { font-size: 13px; }
@@ -2282,8 +2057,6 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px; color: var(--text-secondary); transition: background 0.15s; font-size: 14px; color: var(--text-secondary); transition: background 0.15s;
} }
.trash-item-btn:hover { background: var(--bg-tertiary); } .trash-item-btn:hover { background: var(--bg-tertiary); }
.ml-auto { margin-left: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
@@ -2362,11 +2135,6 @@ button.breadcrumb-current { max-width: 260px; }
text-align: center; text-align: center;
} }
.blocks-empty-hint {
color: var(--text-dim);
font-size: 15px;
}
/* Block wrapper */ /* Block wrapper */
.block-wrapper { .block-wrapper {
position: relative; position: relative;
@@ -2502,7 +2270,6 @@ button.breadcrumb-current { max-width: 260px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; } .ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
.ftable-hdr-toggle input { cursor:pointer; } .ftable-hdr-toggle input { cursor:pointer; }
/* Block actions (+ button) */ /* Block actions (+ button) */
.block-actions { .block-actions {
position: absolute; position: absolute;
@@ -2534,23 +2301,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-primary); color: var(--text-primary);
} }
.block-drag-btn {
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 11px;
cursor: grab;
transition: background 0.1s;
}
.block-drag-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Block content (base) */ /* Block content (base) */
.block-content { .block-content {
flex: 1; flex: 1;
@@ -2570,36 +2320,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: normal; font-style: normal;
} }
/* Headings */
.block-h1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
padding: 8px 2px 4px;
}
.block-h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
padding: 6px 2px 3px;
}
.block-h3 {
font-size: 20px;
font-weight: 600;
line-height: 1.3;
padding: 4px 2px 2px;
}
.block-h4 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
padding: 4px 2px 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
/* Bulleted list */ /* Bulleted list */
.block-bullet { .block-bullet {
padding-left: 18px; padding-left: 18px;
@@ -2685,13 +2405,6 @@ button.breadcrumb-current { max-width: 260px; }
word-break: break-word; word-break: break-word;
} }
/* Quote */
.block-quote {
border-left: 3px solid var(--text-primary);
padding: 4px 14px;
margin: 4px 0;
}
/* Code block */ /* Code block */
.block-code { .block-code {
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -2835,11 +2548,6 @@ button.breadcrumb-current { max-width: 260px; }
font-style: italic; font-style: italic;
padding: 12px 16px; padding: 12px 16px;
} }
/* Math block */
.block-math {
margin: 8px 0;
}
.math-display { .math-display {
min-height: 40px; min-height: 40px;
padding: 10px 14px; padding: 10px 14px;
@@ -2953,28 +2661,6 @@ button.breadcrumb-current { max-width: 260px; }
display: none; display: none;
} }
/* Image block */
.block-image-wrapper {
margin: 8px 0;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: var(--bg-secondary);
border: 1px dashed var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s;
}
.image-placeholder:hover {
background: var(--bg-hover);
}
/* SLASH COMMAND MENU */ /* SLASH COMMAND MENU */
.slash-menu { .slash-menu {
position: fixed; position: fixed;
@@ -2990,45 +2676,6 @@ button.breadcrumb-current { max-width: 260px; }
flex-direction: column; flex-direction: column;
} }
.slash-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.slash-search input {
width: 100%;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.slash-search input:focus {
border-color: var(--blue);
}
.slash-groups {
overflow-y: auto;
padding: 4px 0;
}
.slash-group {
padding: 0;
}
.slash-group + .slash-group {
border-top: 1px solid var(--border);
}
.slash-group-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slash-item { .slash-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3058,27 +2705,10 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 4px; border-radius: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
.slash-item-info {
flex: 1;
display: flex;
flex-direction: column;
}
.slash-item-name { .slash-item-name {
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
} }
.slash-item-desc {
font-size: 12px;
color: var(--text-dim);
}
.slash-item-shortcut {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
flex-shrink: 0;
}
/* FORMAT TOOLBAR (FLOATING) */ /* FORMAT TOOLBAR (FLOATING) */
.format-toolbar { .format-toolbar {
@@ -3110,13 +2740,6 @@ button.breadcrumb-current { max-width: 260px; }
background: var(--bg-hover); background: var(--bg-hover);
} }
.format-separator {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
/* EDITOR STATUSBAR */ /* EDITOR STATUSBAR */
.editor-statusbar { .editor-statusbar {
position: fixed; position: fixed;
@@ -3266,15 +2889,6 @@ button.breadcrumb-current { max-width: 260px; }
.fm-manual { margin-top: 8px; } .fm-manual { margin-top: 8px; }
.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; } .fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
.fm-transcript { max-height: 40vh; overflow-y: auto; } .fm-transcript { max-height: 40vh; overflow-y: auto; }
/* ═══════════ Page Editor — Top Bar ═══════════ */
.page-topbar {
display: flex; align-items: center; justify-content: flex-end;
padding: 6px 0; min-height: 36px;
}
.page-topbar-right {
display: flex; align-items: center; gap: 4px; position: relative;
}
.topbar-edited { .topbar-edited {
font-size: 12px; color: var(--text-dim); margin-right: 8px; font-size: 12px; color: var(--text-dim); margin-right: 8px;
} }
@@ -3435,12 +3049,6 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: var(--radius-lg); box-shadow: var(--shadow-popover); border-radius: var(--radius-lg); box-shadow: var(--shadow-popover);
z-index: 999; padding: 4px 0; z-index: 999; padding: 4px 0;
} }
.sd-access-option {
padding: 8px 14px; font-size: 13px; color: var(--text-primary);
cursor: pointer; display: flex; align-items: center; gap: 8px;
}
.sd-access-option:hover { background: var(--bg-hover); }
.sd-access-option.active { background: rgba(35,131,226,.15); }
/* Permissions */ /* Permissions */
.sd-select-sm { .sd-select-sm {
@@ -3602,22 +3210,6 @@ button.breadcrumb-current { max-width: 260px; }
.toggle-switch.sm .toggle-slider:before { height: 14px; width: 14px; } .toggle-switch.sm .toggle-slider:before { height: 14px; width: 14px; }
.toggle-switch.sm input:checked + .toggle-slider:before { transform: translateX(14px); } .toggle-switch.sm input:checked + .toggle-slider:before { transform: translateX(14px); }
/* ═══════════ Empty Page Toolbar ═══════════ */
.empty-page-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 12px 0; flex-wrap: wrap;
}
.empty-toolbar-label {
font-size: 13px; color: var(--text-dim); margin-right: 6px;
}
.empty-toolbar-item {
padding: 6px 12px; background: var(--bg-secondary);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text-primary); font-size: 13px; cursor: pointer;
white-space: nowrap;
}
.empty-toolbar-item:hover { background: var(--bg-hover); border-color: var(--text-dim); }
/* ═══════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════
RESPONSIVE — MOBILE-FIRST DESIGN SYSTEM RESPONSIVE — MOBILE-FIRST DESIGN SYSTEM
Breakpoints: ≤480px phone | ≤768px tablet | ≤1024px small desktop Breakpoints: ≤480px phone | ≤768px tablet | ≤1024px small desktop
@@ -3654,20 +3246,6 @@ button.breadcrumb-current { max-width: 260px; }
opacity: 1; pointer-events: auto; opacity: 1; pointer-events: auto;
} }
/* ── Sidebar Back Button (mobile) ── */
.sidebar-back-btn {
display: none;
width: 36px; height: 36px;
border-radius: var(--radius-sm);
align-items: center; justify-content: center;
color: var(--text-primary); font-size: 18px;
cursor: pointer; transition: background var(--transition);
}
.sidebar-back-btn:hover { background: var(--bg-hover); }
/* ── Topbar mobile spacer ── */
.topbar-mobile-spacer { flex: 1; min-width: 0; }
/* ═══════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════
TABLET (≤1024px) TABLET (≤1024px)
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
@@ -3764,7 +3342,6 @@ button.breadcrumb-current { max-width: 260px; }
.topbar-right span.text-xs { display: none; } .topbar-right span.text-xs { display: none; }
.topbar-breadcrumb { font-size: 13px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .topbar-breadcrumb { font-size: 13px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Content ── */ /* ── Content ── */
.content-area { overflow-y: auto; } .content-area { overflow-y: auto; }
.page-cover-area { padding: 24px 16px 8px; } .page-cover-area { padding: 24px 16px 8px; }
@@ -3909,15 +3486,6 @@ button.breadcrumb-current { max-width: 260px; }
.btn-new { height: 36px; padding: 8px 12px !important; } .btn-new { height: 36px; padding: 8px 12px !important; }
} }
/* ══════════ Uncollapse sidebar button ══════════ */
.sidebar-uncollapse-btn {
position: fixed; top: 60px; left: 12px; z-index: 1001;
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-secondary); cursor: pointer;
}
.sidebar-uncollapse-btn:hover { background: var(--bg-hover); color: var(--text); }
/* ═══════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════
SHARED COMPONENTS (migrated from design-tokens.css) SHARED COMPONENTS (migrated from design-tokens.css)
═══════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════ */
@@ -3936,22 +3504,6 @@ button.breadcrumb-current { max-width: 260px; }
50% { opacity: .5; } 50% { opacity: .5; }
} }
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
pointer-events: none;
}
.skeleton-card .skeleton-line {
height: 14px;
margin-bottom: 10px;
border-radius: 4px;
}
.skeleton-card .skeleton-line:last-child { margin-bottom: 0; }
.skeleton-file { .skeleton-file {
padding: 16px; padding: 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -3961,20 +3513,6 @@ button.breadcrumb-current { max-width: 260px; }
overflow: hidden; overflow: hidden;
} }
.skeleton-line {
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-table { overflow: hidden; }
.skeleton-table .skeleton-line {
height: 40px;
margin-bottom: 2px;
border-radius: 0;
}
/* Toast */ /* Toast */
.toast-container { .toast-container {
position: fixed; position: fixed;
@@ -4005,16 +3543,6 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--toast-success-text); color: var(--toast-success-text);
} }
.toast-error {
background: var(--toast-error-bg);
color: var(--toast-error-text);
}
.toast-info {
background: var(--toast-info-bg);
color: var(--toast-info-text);
}
@keyframes toast-slide-in { @keyframes toast-slide-in {
from { from {
opacity: 0; opacity: 0;
@@ -4132,21 +3660,6 @@ button.breadcrumb-current { max-width: 260px; }
box-shadow: 0 0 0 2px rgba(35,131,226,.15); box-shadow: 0 0 0 2px rgba(35,131,226,.15);
} }
.settings-select {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 6px 10px;
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 140px;
font-family: var(--font-sans);
}
.settings-select:focus { border-color: var(--accent); }
/* Dialog */ /* Dialog */
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
@@ -4420,7 +3933,6 @@ button.breadcrumb-current { max-width: 260px; }
.db-row:hover { background: var(--bg-hover); } .db-row:hover { background: var(--bg-hover); }
.db-row-new { border-bottom: 1px solid var(--border); } .db-row-new { border-bottom: 1px solid var(--border); }
.db-row-empty { height: 32px; } .db-row-empty { height: 32px; }
.db-row-selected { background: var(--bg-active); }
.db-td { .db-td {
padding: 3px 8px; padding: 3px 8px;
+12
View File
@@ -72,6 +72,18 @@ def anon(client):
return client return client
def anon_csrf(client):
"""Anonyme MAIS CSRF valide — comme un navigateur qui a déjà chargé une page.
Sert aux tests d'"isolation auth" : on veut le 401 de la route, pas le 403
du middleware CSRF qui passerait avant.
"""
anon(client)
client.cookies.set("csrf_token", "csrf-anon")
client.headers["X-CSRF-Token"] = "csrf-anon"
return client
@pytest.fixture @pytest.fixture
def client(): def client():
"""FastAPI TestClient with a fresh temporary SQLite database.""" """FastAPI TestClient with a fresh temporary SQLite database."""
+3 -3
View File
@@ -4,7 +4,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client from conftest import anon, anon_csrf, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -1670,7 +1670,7 @@ def test_gitea_status_with_expired_token(client):
def test_gitea_disconnect_no_auth(client): def test_gitea_disconnect_no_auth(client):
anon(client) anon_csrf(client)
"""DELETE /api/gitea/disconnect — 401 without session.""" """DELETE /api/gitea/disconnect — 401 without session."""
resp = client.delete("/api/gitea/disconnect") resp = client.delete("/api/gitea/disconnect")
assert resp.status_code == 401 assert resp.status_code == 401
@@ -1952,7 +1952,7 @@ def test_gitea_private_pages_list_no_auth(client):
def test_gitea_private_pages_create_no_auth(client): def test_gitea_private_pages_create_no_auth(client):
anon(client) anon_csrf(client)
"""POST private-pages — 401 without session.""" """POST private-pages — 401 without session."""
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"}) resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
assert resp.status_code == 401 assert resp.status_code == 401
+100 -2
View File
@@ -1,5 +1,5 @@
"""Non-régression de l'audit sécurité 2026-09-30 — A11 (traversal) et A18 (XSS public).""" """Non-régression de l'audit sécurité 2026-09-30 — A11 (traversal) et A18 (XSS public)."""
from conftest import anon from conftest import anon, anon_csrf
def test_avatar_path_traversal_denied(client): def test_avatar_path_traversal_denied(client):
@@ -36,6 +36,64 @@ def test_public_view_hides_restricted_collection(client):
assert "Internal" not in r.text assert "Internal" not in r.text
def test_rate_limit_key_and_prune():
"""A33 : XFF ignoré depuis une IP publique (anti-bypass), épurage du store."""
from types import SimpleNamespace
from app.middleware.security import RateLimitMiddleware
mw = RateLimitMiddleware(None)
def req(host, fwd=None):
headers = {"x-forwarded-for": fwd} if fwd else {}
return SimpleNamespace(headers=headers, client=SimpleNamespace(host=host))
# IP publique (globale) : le client peut spoofer XFF autant qu'il veut → clé d'origine
assert mw._client_key(req("8.8.8.8", "1.2.3.4")) == "8.8.8.8"
# Derrière un proxy local : on prend le premier hop XFF
assert mw._client_key(req("10.0.0.1", "198.51.100.7, 10.0.0.2")) == "198.51.100.7"
# Sans XFF / hôte non IP (testserver)
assert mw._client_key(req("testserver")) == "testserver"
# Épurage : les fenêtres expirées sortent du store
import time
now = time.time()
mw._store["old"] = (now - 3600, 5)
mw._store["fresh"] = (now, 1)
mw._prune(now)
assert "old" not in mw._store and "fresh" in mw._store
def test_cors_no_star(client):
"""A37 : plus de `*` — origine refusée n'a pas d'ACAO, origine autorisée oui."""
r = client.get("/api/health", headers={"Origin": "https://evil.example"})
assert "access-control-allow-origin" not in r.headers
r2 = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
assert r2.headers.get("access-control-allow-origin") == "http://localhost:8080"
def test_asset_version_single_source():
"""A40 : une seule source de version d'assets = le fichier VERSION."""
import re as _re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
version = (root / "VERSION").read_text(encoding="utf-8").strip()
from app.templating import ASSET_VERSION, ENV
assert ASSET_VERSION == version
assert ENV.globals["asset_version"] == version
src = (root / "app/templates/base.html").read_text(encoding="utf-8")
assert "app.css?v={{ asset_version }}" in src
assert "app.js?v={{ asset_version }}" in src
# plus aucun littéral de version première main dans les templates
literals = _re.findall(
r"(?:app|design-tokens|components|offline|flowdeck)\.(?:css|js)\?v=\d", src
)
assert literals == [], literals
def test_no_duplicate_routes(): def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre.""" """A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app from app.main import app
@@ -60,6 +118,7 @@ def test_og_metadata_rejects_private_host(client):
def test_automations_require_session(client): def test_automations_require_session(client):
"""A13 : CRUD, run et press-button refusent un anonymous.""" """A13 : CRUD, run et press-button refusent un anonymous."""
anon(client) anon(client)
anon_csrf(client)
assert client.post("/workspace/automations", json={"name": "x"}).status_code == 401 assert client.post("/workspace/automations", json={"name": "x"}).status_code == 401
assert client.post("/workspace/automations/1/run", json={}).status_code == 401 assert client.post("/workspace/automations/1/run", json={}).status_code == 401
assert client.post("/api/automations/press-button", json={}).status_code == 401 assert client.post("/api/automations/press-button", json={}).status_code == 401
@@ -73,6 +132,7 @@ def test_outbound_webhook_requires_admin_and_public_url(client):
assert r.status_code == 400 assert r.status_code == 400
anon(client) anon(client)
anon_csrf(client)
assert client.post("/workspace/webhooks", json={"url": "https://example.com/h"}).status_code == 401 assert client.post("/workspace/webhooks", json={"url": "https://example.com/h"}).status_code == 401
@@ -94,11 +154,49 @@ def test_upload_requires_session_and_validates_files(client):
assert validate_upload("virus.exe", 10) is not None assert validate_upload("virus.exe", 10) is not None
assert validate_upload("big.txt", 11 * 1024 * 1024) is not None assert validate_upload("big.txt", 11 * 1024 * 1024) is not None
anon(client) anon_csrf(client)
r = client.post("/api/local-workspace/upload", files={"files": ("a.txt", b"x", "text/plain")}) r = client.post("/api/local-workspace/upload", files={"files": ("a.txt", b"x", "text/plain")})
assert r.status_code == 401 assert r.status_code == 401
def test_agent_providers_require_admin_and_valid_api_base(client):
"""A14 : plus de fallback `admin` — un anonymous ne dirige plus le ping."""
# admin de la fixture : scheme non-http refusé, identifiants refusés
r = client.patch("/api/agent/providers", json={"provider": "mistral", "api_base": "ftp://x.test/v1"})
assert r.status_code == 400, r.text
r2 = client.post("/api/agent/providers/test", json={"provider": "mistral", "api_base": "https://user:[email protected]/v1"})
assert r2.status_code == 400, r2.text
anon_csrf(client)
assert client.patch("/api/agent/providers", json={"provider": "ollama"}).status_code == 401
assert client.post("/api/agent/providers/test", json={"provider": "ollama"}).status_code == 401
def test_collection_rollback_when_materialize_fails(client, monkeypatch):
"""A25 : un échec de `materialize_properties` ne doit pas commiter la collection."""
import pytest
from app.services import db_templates
def boom(*_a, **_k):
raise RuntimeError("materialize boom")
monkeypatch.setattr(db_templates, "materialize_properties", boom)
tok = client.post("/api/v1/token").json()["token"]
with pytest.raises(RuntimeError):
client.post(
"/api/v2/collections",
json={"name": "Broken", "schema": [{"name": "Title", "type": "title"}]},
headers={"Authorization": f"Bearer {tok}"},
)
from app.db import get_conn
with get_conn() as conn:
n = conn.execute("SELECT COUNT(*) FROM collections WHERE name='Broken'").fetchone()[0]
assert n == 0, "la collection ne doit pas survivre à un schéma non matérialisé"
def test_exports_and_attachments_require_auth(client): def test_exports_and_attachments_require_auth(client):
"""A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu).""" """A16 : export + pièces jointes = session et `can_view_page` (jamais le contenu)."""
pid = client.post("/board/api/pages?title=Secret&section=Private").json()["id"] pid = client.post("/board/api/pages?title=Secret&section=Private").json()["id"]
+2 -2
View File
@@ -8,7 +8,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client from conftest import anon_csrf, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -110,7 +110,7 @@ def test_share_invalid_permission_rejected(client):
def test_share_requires_auth(client): def test_share_requires_auth(client):
anon(client) anon_csrf(client)
pid = _make_page(client) pid = _make_page(client)
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"}) r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
assert r.status_code == 401 assert r.status_code == 401
+6 -3
View File
@@ -8,7 +8,7 @@ import asyncio
import os import os
import pytest import pytest
from conftest import anon from conftest import anon_csrf
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -67,7 +67,7 @@ def test_api_token_lifecycle(client):
def test_api_tokens_require_authentication(client): def test_api_tokens_require_authentication(client):
anon(client) anon_csrf(client)
r1 = client.get("/api/settings/tokens") r1 = client.get("/api/settings/tokens")
assert r1.status_code == 401 assert r1.status_code == 401
r2 = client.post("/api/settings/tokens", json={"name": "x"}) r2 = client.post("/api/settings/tokens", json={"name": "x"})
@@ -106,6 +106,9 @@ def test_sessions_listed_and_revocable(client):
# Create a fresh client with alice's cookie to revoke. # Create a fresh client with alice's cookie to revoke.
client_alice = TestClient(client.app) client_alice = TestClient(client.app)
client_alice.cookies.set("flowdeck_session", alice_cookie) client_alice.cookies.set("flowdeck_session", alice_cookie)
# CSRF aussi sur ce client jetable (la route n'est plus exemptée, A19).
client_alice.cookies.set("csrf_token", "csrf-alice")
client_alice.headers["X-CSRF-Token"] = "csrf-alice"
revoke = client_alice.post(f"/api/settings/sessions/{alice_sid}/revoke") revoke = client_alice.post(f"/api/settings/sessions/{alice_sid}/revoke")
assert revoke.status_code == 200 assert revoke.status_code == 200
@@ -198,7 +201,7 @@ def test_backup_disabled_returns_none(client):
def test_backup_admin_api(client): def test_backup_admin_api(client):
anon(client) anon_csrf(client)
"""The backup admin API is admin-only and snapshots on demand.""" """The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden. # Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403 assert client.post("/api/settings/backups/run").status_code == 403
+2 -2
View File
@@ -217,8 +217,8 @@ class TestV54TrashPurge:
with get_conn() as conn: with get_conn() as conn:
# insert 2 deleted pages: one old, one recent # insert 2 deleted pages: one old, one recent
old = datetime.datetime.utcnow() - datetime.timedelta(days=45) old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45)
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5) recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5)
cur = conn.execute( cur = conn.execute(
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)", "INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
("test", "Old Page", old.isoformat()), ("test", "Old Page", old.isoformat()),
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import secrets import secrets
import pytest import pytest
from conftest import anon from conftest import anon, anon_csrf
from app.db import get_conn from app.db import get_conn
from app.services import automations as auto_svc from app.services import automations as auto_svc
@@ -114,7 +114,7 @@ def test_steps_crud_and_order(client):
def test_steps_validation_and_auth(client): def test_steps_validation_and_auth(client):
anon(client) anon_csrf(client)
session, _ = _login(client) session, _ = _login(client)
aid = _mkauto(client, session) aid = _mkauto(client, session)
r = client.post(f"/workspace/automations/{aid}/steps", r = client.post(f"/workspace/automations/{aid}/steps",
+3 -3
View File
@@ -11,7 +11,7 @@ import json
import secrets import secrets
import pytest import pytest
from conftest import anon from conftest import anon_csrf
from app.db import get_conn from app.db import get_conn
from app.services import calendar_sync as cal from app.services import calendar_sync as cal
@@ -118,7 +118,7 @@ def test_link_crud_and_encryption(client):
def test_link_validation_and_auth(client): def test_link_validation_and_auth(client):
anon(client) anon_csrf(client)
session, _ = _login(client) session, _ = _login(client)
cid = _mkcollection(client) cid = _mkcollection(client)
r = client.post("/api/v2/calendar-links", r = client.post("/api/v2/calendar-links",
@@ -281,7 +281,7 @@ def test_freebusy_basic(client):
def test_freebusy_validation(client): def test_freebusy_validation(client):
anon(client) anon_csrf(client)
session, _ = _login(client) session, _ = _login(client)
cid = _mkcollection(client) cid = _mkcollection(client)
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01", r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01",