Compare commits

...
3 Commits
Author SHA1 Message Date
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
40 changed files with 3506 additions and 229 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
DEFAULT_LANG=fr
# ── Database ──
# SQLite (default): sqlite:////data/flowdeck.db
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
# schéma retombe silencieusement sur /data/flowdeck.db).
DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ──
+61
View File
@@ -1,5 +1,66 @@
# Changelog - FlowDeck
## v7.3.9 (2026-10-01) — Audit : A26, A33, A34, A35, A36, A43
### Fixed
- **A26** — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…`
ne produit plus un chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL (non supporté) ; **raise au boot** si `APP_SECRET_KEY` vaut encore
la valeur par défaut (il signe les sessions)
- **A33** — rate limit : préfixes manquants ajoutés (`/scim/v2/`, `/workspace/`,
`/db/`, plus le non-GET sur `/s/` et `/f/` sans pénaliser la lecture) ; la
limite vient de `settings.rate_limit_requests` (60 annoncés, 100 codés en dur) ;
clé = `X-Forwarded-For` uniquement derrière un proxy local ; `_store` épuré
(croissance mémoire bornée)
- **A34** — helper `_spawn()` pour les 10 schedulers : exception loggée +
redémarrage après 10 s (ils mouraient en silence) ; 2 `logger.debug` de
scheduler passés en `warning`
- **A35** — OpenAPI régénéré : 439 → **511 chemins**, `info.version 7.3.9` ;
README à jour (était v6.7.0) ; compteur de `API_GUIDE_V6.md` à jour ; titre
dupliqué retiré du ROADMAP
- **A36** — 4 dépendances mortes purgées de `requirements.txt`
(`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import)
- **A43** — 15 `datetime.utcnow()` dépréciés → `now(UTC).replace(tzinfo=None)`
(format ISO naïf identique, zéro changement de comportement)
### Notes
- Le drift Python (Docker/CI/README 3.12 vs venv local 3.13) reste ouvert :
l'alignement à 3.13 implique un rebuild d'image à valider
## v7.3.8 (2026-10-01) — Audit : A25 (exceptions muettes) + A21 partiel
### Fixed
- **A25** — 84 `except Exception: pass/…` deviennent `logger.exception(fn)`
(19 fichiers, 63 dans des handlers `async`) : les échecs du pipeline
d'événements/webhooks et des écritures sont enfin visibles dans les logs
- **A25 (critique)** — plus de `try` autour de `materialize_properties` dans
`create_collection_v2` et `apply_db_template_v2` : un échec annule la
transaction au lieu de commiter une collection sans schéma
- **A21 (partiel)** — `PRAGMA busy_timeout=5000` dans `get_conn()` (le seul
point d'entrée des connexions) ; le wrapper async + les 510 call sites
synchrones sur l'event loop restent à migrer
### Tests
- `test_collection_rollback_when_materialize_fails` → suite **1028/1028**
## v7.3.7 (2026-09-30) — Audit sécurité : A14 (fallback admin agent)
### Fixed
- **A14** — `_current_user_id` et `_current_admin` ne retombent plus sur la
row `admin` : 401 sans session (les 24 sites de `_current_user_id` +
`PATCH/POST /api/agent/providers`) — un anonymous ne pouvait plus orienter le
`ping()` du serveur vers un `api_base` interne
- `_check_api_base()` sur les 2 routes provider : scheme `http(s)` obligatoire,
identifiants dans l'URL refusés (400). Les hôtes privés restent acceptés —
le provider par défaut du produit est Ollama `http://localhost:11434/v1`
( commentaire `ponytail:` : fermeture possible via allowlist provider local)
- Test `test_agent_providers_require_admin_and_valid_api_base` → suite **1027/1027**
## v7.3.6 (2026-09-30) — Audit sécurité : A19 terminé (aucun préfixe cookie-auth exempt)
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
## Quick Start
+10 -14
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)
| Feature | Fichiers | Note |
@@ -1135,32 +1131,32 @@ 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] **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] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [ ] **A20 — CSP sans filet : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'` (Alpine/HTMX n'en ont pas besoin par défaut), resserrer `img-src`/`connect-src`. Effort : **L**.*
- [ ] **A21 — `sqlite3` synchrone sur l'event loop** : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [ ] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`. Effort : **M**.*
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
- [ ] **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
- [ ] **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**.*
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.*
- [ ] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent. Effort : **M**.*
- [ ] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [ ] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration. Effort : **M**.*
- [ ] **A32 — Couverture de tests par trou** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante). Effort : **M**.*
- [ ] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [ ] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [ ] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**.*
- [ ] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).*
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [ ] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*
### 🟢 P3 — Basses / hygiène
@@ -1170,7 +1166,7 @@ Quality DB views, Agent IA Palette → Realtime + E
- [ ] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [ ] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [ ] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé. Effort : **M**.*
- [ ] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
- [x] **A43 — Dette mineure** : 22 `datetime.utcnow()` dépréciés (warnings dans les tests), `health` (`api.py:49`) avale db **et** gitea sans log (« degraded » sans raison + 1 aller-retour réseau par probe), `base.html:120` sert le littéral `__CSRF_PLACEHOLDER__` rempli côté JS (fenêtre de course) et `base.html:2292` re-parse ce JSON **à chaque frappe** de la palette sur un GET (où le CSRF ne s'applique pas). *Effort : **XS**.*
### ✅ Vérifié non-problème (ne pas re-checker)
@@ -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.
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6.**
→ **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.**
+1 -1
View File
@@ -1 +1 @@
7.3.6
7.3.9
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.3.6 (audit sécurité — A19 terminé) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.3.9 (audit — A26/A33/A34/A35/A36/A43) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import UTC, datetime
from uuid import uuid4
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
@@ -31,7 +31,7 @@ class SessionManager:
"""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -94,7 +94,7 @@ class SessionManager:
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
user_id = user_data.get("id")
if user_id:
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
)
conn.commit()
except Exception:
pass
logger.exception("_touch_session")
# FastAPI dependency
+3 -4
View File
@@ -22,9 +22,6 @@ class Settings(BaseSettings):
github_oauth_client_id: str = ""
github_oauth_client_secret: str = ""
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = ""
@@ -129,7 +126,9 @@ class Settings(BaseSettings):
import re
if re.match(r'^[a-zA-Z]:', p):
return Path(p)
return Path("/" + p)
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
return Path("/" + p.lstrip("/"))
return Path("/data/flowdeck.db")
+5
View File
@@ -837,6 +837,11 @@ def get_conn():
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
# l'event loop) reste à migrer module par module.
conn.execute("PRAGMA busy_timeout=5000")
try:
yield conn
finally:
+42 -10
View File
@@ -71,6 +71,31 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _spawn(name: str, factory):
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
Loggue l'exception puis recrée la coroutine 10 s plus tard.
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
le bruit devient un problème.
"""
async def _guard():
while True:
try:
await factory()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
await asyncio.sleep(10)
else:
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
await asyncio.sleep(10)
return asyncio.create_task(_guard())
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
@@ -81,6 +106,13 @@ async def lifespan(_app: FastAPI):
from app.db import get_conn
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
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
with get_conn() as conn:
@@ -99,41 +131,41 @@ async def lifespan(_app: FastAPI):
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler())
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
# ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
automation_task = _spawn("automation_scheduler", automation_scheduler)
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler())
backup_task = _spawn("backup_scheduler", backup_scheduler)
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler())
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = 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 ──
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 ──
from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None
if settings.webhook_retry_enabled:
webhook_task = asyncio.create_task(webhook_retry_scheduler())
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try:
@@ -153,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.6",
version="7.3.9",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+56 -6
View File
@@ -1,6 +1,7 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations
import ipaddress
import time
from collections import defaultdict
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
# Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/",
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
# API workspace + collections (les endpoints mutants du legacy).
"/scim/v2/", "/workspace/", "/db/",
)
# Pages publiques : seul le non-GET est plafonné (brute force de
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
# visiteurs d'un site publié qui partagent une IP.
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
# Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health",
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"/api/frontend-errors",
})
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
super().__init__(app)
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
self.max_requests = max_requests
self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
self._last_prune = 0.0
self._max_keys = 5000
async def dispatch(self, request: Request, call_next):
path = request.url.path
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if not settings.rate_limit_enabled:
return await call_next(request)
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
# Only rate-limit API routes (+ non-GET sur les pages publiques)
method = request.method.upper()
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
method not in ("GET", "HEAD", "OPTIONS")
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
)
if not limited:
return await call_next(request)
# Exempt health check and error capture
if path in self.EXEMPT_PATHS:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
limit = self.max_requests or settings.rate_limit_requests
ip = self._client_key(request)
now = time.time()
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
self._prune(now)
window_start, count = self._store[ip]
if now - window_start > self.window_seconds:
self._store[ip] = (now, 1)
return await call_next(request)
if count >= self.max_requests:
if count >= limit:
return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
status_code=429,
)
self._store[ip] = (window_start, count + 1)
return await call_next(request)
def _client_key(self, request: Request) -> str:
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
globales, pas seulement RFC1918) — un pair non-global n'est pas un
internaute, donc le XFF du proxy fait foi.
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
exposée directement), prendre la dernière adresse non privée de la
chaîne plutôt que la première.
"""
host = request.client.host if request.client else "unknown"
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
try:
direct = ipaddress.ip_address(host)
if direct.is_private or direct.is_loopback:
return fwd.split(",")[0].strip() or host
except ValueError:
pass # hôte non-IP (testserver…) → on garde la clé d'origine
return host
def _prune(self, now: float) -> None:
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
for k in expired:
del self._store[k]
self._last_prune = now
+1 -1
View File
@@ -862,7 +862,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
try:
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
except Exception:
pass
logger.exception("_migration_v630_api_v2")
conn.execute(
"""CREATE TABLE IF NOT EXISTS api_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+44 -24
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -51,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
triggers = conn.execute(
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
).fetchall()
now = datetime.utcnow()
now = datetime.now(UTC).replace(tzinfo=None)
for trig in triggers:
last = trig["last_fired_at"]
if last:
@@ -92,13 +93,12 @@ async def agent_scheduler(interval_seconds: int = 60):
logger.exception("Agent scheduler tick failed")
async def _current_user_id(request: Request) -> int | None:
async def _current_user_id(request: Request) -> int:
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
user = await get_current_user(request)
if user and user.get("id"):
return user["id"]
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else None
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user["id"]
async def _workspace_id(request: Request) -> int | None:
@@ -113,22 +113,19 @@ async def _workspace_id(request: Request) -> int | None:
async def _current_admin(request: Request) -> dict:
"""Require an admin session. Falls back to the single admin row, matching
the agent router's unauthenticated convention (single-user deployments)."""
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
`ping()` vers un `api_base` de son choix = SSRF)."""
user = await get_current_user(request)
if user:
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return dict(row)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
if not user.get("is_admin"):
from app.db import get_conn as _gc
with _gc() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Accès administrateur requis")
return user
def _default_agent(conn, user_id: int) -> dict:
@@ -997,6 +994,29 @@ async def fetch_llm_models(request: Request, llm_provider: str):
return {"ok": False, "provider": provider, "error": str(exc)}
def _check_api_base(value: str) -> str:
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
settings `llm_allow_private=false`.
"""
url = (value or "").strip()
if not url:
return ""
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
return url
@router.patch("/providers")
async def update_provider_config(request: Request):
await _current_admin(request)
@@ -1008,7 +1028,7 @@ async def update_provider_config(request: Request):
provider=provider or None,
model=(body.get("model") or "").strip() or None,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
clear_keys=(provider == "offline"),
)
llm = LLMClient()
@@ -1037,7 +1057,7 @@ async def test_provider_config(request: Request):
llm = LLMClient(
provider=provider,
api_key=body.get("api_key"),
api_base=(body.get("api_base") or "").strip() or None,
api_base=_check_api_base(body.get("api_base") or "") or None,
)
try:
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
@@ -46,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
now = datetime.utcnow().timestamp()
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
window_start, count = _rate_limit_store.get(ip, (0, 0))
if now - window_start > 60:
_rate_limit_store[ip] = (now, 1)
@@ -67,12 +67,12 @@ async def health(request: Request):
conn.execute("SELECT 1")
db_ok = True
except Exception:
pass
logger.exception("health")
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
pass
logger.exception("health")
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
+37 -42
View File
@@ -165,7 +165,7 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
try:
d[k] = json.loads(d[k] or "{}")
except Exception:
pass
logger.exception("get_me")
# never expose secrets
return d
@@ -261,7 +261,7 @@ async def create_workspace(request: Request, authorization: str | None = Header(
try:
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
except Exception:
pass
logger.exception("create_workspace")
conn.commit()
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
audit_log(user, "workspace.create", "workspace", wid, name, request)
@@ -497,17 +497,13 @@ async def create_collection_v2(request: Request, authorization: str | None = Hea
with get_conn() as conn:
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
cid = cur.lastrowid
# materialize properties if schema provided
try:
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
except Exception:
pass
# materialize properties if schema provided — A25 : PAS de try ici,
# une exception doit interrompre la transaction (sinon la collection est
# commitée sans son schéma et l'erreur disparaît).
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
# default view
try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
except Exception:
pass
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request)
@@ -593,20 +589,20 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
try:
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
except Exception:
pass
logger.exception("create_linked_db")
# copy views + properties (light)
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
for p in rows:
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
except Exception:
pass
logger.exception("create_linked_db")
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
for v in vrows:
try:
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
except Exception:
pass
logger.exception("create_linked_db")
conn.commit()
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
return {"id": nid, "name": name, "status": "created"}
@@ -766,7 +762,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
pass
# light validation: we rely on existing validators
except Exception:
pass
logger.exception("create_collection_page_v2")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
# apply auto props
try:
@@ -774,7 +770,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, prop_vals, user, is_create=True)
except Exception:
pass
logger.exception("create_collection_page_v2")
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
pid = cur.lastrowid
conn.commit()
@@ -783,7 +779,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
except Exception:
pass
logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
@@ -857,14 +853,14 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
from app.services.property_types import apply_auto_properties as _aap
_aap(props_list, stored, user, is_create=False)
except Exception:
pass
logger.exception("patch_page_v2")
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
except Exception:
pass
logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}")
@@ -883,7 +879,7 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
except Exception:
pass
logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore")
@@ -901,7 +897,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.restored", {"page_id": page_id})
except Exception:
pass
logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"}
raise HTTPException(404, "Page not found or not deleted")
@@ -1109,7 +1105,7 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
try:
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
except Exception:
pass
logger.exception("create_relation_v2")
conn.commit()
return {"id": prop_id, "status": "updated"}
@@ -1184,7 +1180,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
except Exception:
pass
logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@router.patch("/views/{view_id}")
@@ -1208,7 +1204,7 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
try:
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
except Exception:
pass
logger.exception("patch_view_v2")
# also flat keys
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
if k in body:
@@ -1350,7 +1346,7 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
except Exception:
pass
logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}")
@@ -1378,7 +1374,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}")
@@ -1419,13 +1415,13 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
created += 1
except Exception:
pass
logger.exception("create_mention_v2")
conn.commit()
if created:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
except Exception:
pass
logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"}
# ── Notifications ─────────────────────────────────────────────────────────
@@ -1522,7 +1518,7 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
except Exception:
pass
logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}")
@@ -1537,7 +1533,7 @@ async def remove_favorite_v2(page_id: int, request: Request, authorization: str
try:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
except Exception:
pass
logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"}
@router.get("/tags")
@@ -1687,7 +1683,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
except Exception:
pass
logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"}
@router.patch("/shares/{share_id}")
@@ -1748,7 +1744,7 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page_v2")
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
@@ -1763,7 +1759,7 @@ async def unpublish_page_v2(page_id: int, request: Request, authorization: str |
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page_v2")
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1844,7 +1840,7 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
except Exception:
pass
logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}")
@@ -1871,7 +1867,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
except Exception:
pass
logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"}
@router.delete("/sprints/{sprint_id}")
@@ -1937,7 +1933,7 @@ async def burndown_v2(sprint_id: int, request: Request, authorization: str | Non
completed += 1
break
except Exception:
pass
logger.exception("burndown_v2")
remaining = total - completed
# ideal linear
ideal = [round(total * (1 - i / 10)) for i in range(11)]
@@ -2047,11 +2043,10 @@ async def apply_db_template_v2(template_id: int, request: Request, authorization
schema = json.loads(tpl["schema_json"] or "[]")
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
cid = cur.lastrowid
try:
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
except Exception:
pass
# A25 : pas de try — un échec de matérialisation doit interrompre la
# transaction plutôt que de commiter une collection sans schéma.
from app.services.db_templates import materialize_properties
materialize_properties(conn, cid, schema)
conn.commit()
return {"collection_id": cid, "name": name, "status": "created"}
+1 -1
View File
@@ -573,4 +573,4 @@ def _log_login(user_id: int, request: Request):
)
conn.commit()
except Exception:
pass
logger.exception("_log_login")
+9 -9
View File
@@ -695,7 +695,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception:
pass
logger.exception("_sidebar_data")
elif ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -781,7 +781,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name,
@@ -982,7 +982,7 @@ async def add_favorite(request: Request, page_id: int):
try:
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("add_favorite")
return {"status": "added", "page_id": page_id}
@@ -997,7 +997,7 @@ async def remove_favorite(request: Request, page_id: int):
try:
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id}
# ═══════════ Share API ═══════════
@@ -1032,7 +1032,7 @@ async def publish_page(request: Request, page_id: int):
try:
await fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page")
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@@ -1048,7 +1048,7 @@ async def unpublish_page(request: Request, page_id: int):
try:
await fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page")
return {"is_published": False}
@@ -1069,7 +1069,7 @@ async def restore_page(request: Request, page_id: int):
try:
await fire_event("page.restored", {"page_id": page_id})
except Exception:
pass
logger.exception("restore_page")
return {"status": "ok", "restored": page_id}
@@ -1732,7 +1732,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
@@ -2040,7 +2040,7 @@ async def delete_page(request: Request, page_id: int):
if not row:
raise HTTPException(404, "Page not found")
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
return {"status": "ok", "deleted": page_id, "title": row["title"]}
+3 -3
View File
@@ -129,7 +129,7 @@ async def add_comment(request: Request, page_id: int):
if mentioned_ids:
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
except Exception:
pass
logger.exception("add_comment")
return {"id": comment_id, "status": "created"}
@@ -159,7 +159,7 @@ async def notify_page_mentions(request: Request, page_id: int):
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
except Exception:
pass
logger.exception("notify_page_mentions")
return {"mentioned": mentioned}
@@ -190,7 +190,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"id": comment_id, "status": "updated"}
+12 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -52,7 +53,7 @@ def _get_user_or_redirect(request: Request):
if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
pass
logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user
@@ -88,7 +89,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception:
pass
logger.exception("_sidebar_data")
recent_pages = []
for repo in repos[:10]:
@@ -179,7 +180,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
elif t["provider"] == "github":
github_linked = True
except Exception:
pass
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
@@ -193,7 +194,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
if row:
local_ws_id = row["id"]
except Exception:
pass
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
@@ -206,7 +207,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
pass
logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
@@ -771,7 +772,7 @@ async def dashboard(
template = env.get_template("landing.html")
return template.render()
except Exception:
pass
logger.exception("dashboard")
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
@@ -788,7 +789,7 @@ async def dashboard(
).fetchone()
has_gitea = bool(tok)
except Exception:
pass
logger.exception("dashboard")
if not has_gitea:
# Check if user has any workspace
@@ -801,7 +802,7 @@ async def dashboard(
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
logger.exception("dashboard")
return RedirectResponse("/local-workspace", status_code=302)
# ── Gitea user → full dashboard ──
@@ -942,7 +943,7 @@ async def list_workspace_projects(request: Request):
"forge": "gitea",
})
except Exception:
pass
logger.exception("list_workspace_projects")
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@@ -1485,7 +1486,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), item_id),
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
)
conn.commit()
return {"status": "ok"}
@@ -2097,7 +2098,7 @@ async def add_item_tag(request: Request, item_id: int):
)
conn.commit()
except Exception:
pass
logger.exception("add_item_tag")
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
+1 -1
View File
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
+1 -1
View File
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
try:
await websocket.close(code=4401)
except Exception:
pass
logger.exception("ws_page")
return
conn = await manager.connect(websocket, page_id, user)
+1 -1
View File
@@ -117,5 +117,5 @@ async def revoke_session(sid: str, request: Request):
try:
request.session.clear()
except Exception:
pass
logger.exception("revoke_session")
return {"status": "revoked"}
+6 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import re
import unicodedata
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
@@ -130,7 +130,7 @@ async def share_page(page_id: int, request: Request):
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
except Exception:
pass
logger.exception("share_page")
return {
"id": share_id,
@@ -332,7 +332,7 @@ async def publish_page(page_id: int, request: Request):
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
pass
logger.exception("publish_page")
return {
"page_id": page_id,
@@ -363,7 +363,7 @@ async def unpublish_page(page_id: int, request: Request):
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
pass
logger.exception("unpublish_page")
return {
"page_id": page_id,
@@ -399,7 +399,7 @@ async def track_recent(request: Request):
DO UPDATE SET workspace=excluded.workspace,
source_type=excluded.source_type,
accessed_at=excluded.accessed_at""",
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
)
conn.commit()
@@ -407,5 +407,5 @@ async def track_recent(request: Request):
"status": "tracked",
"user_id": user["id"],
"page_id": page_id,
"accessed_at": datetime.utcnow().isoformat(),
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
+10 -5
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hashlib
import html
import json
import logging
import re
import secrets
import time
@@ -35,6 +36,8 @@ from app.services.api_v2_helpers import (
row_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sites"])
_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
blocks = resolve_synced_block(blocks)
except Exception:
pass
logger.exception("_render_page_html")
titles: dict = {}
try:
from app.db import get_conn as _gc
@@ -223,7 +226,7 @@ def _track_view(site_id: int) -> None:
)
conn.commit()
except Exception:
pass
logger.exception("_track_view")
def _form_config(conn, collection_id: int) -> dict:
@@ -589,12 +592,14 @@ async def public_site_auth(request: Request, slug: str):
try:
password = (await request.json()).get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
else:
try:
form = await request.form()
password = form.get("password", "")
except Exception:
logger.exception("public_site_auth")
password = ""
if not verify_password(password or "", site["password_hash"] or ""):
raise HTTPException(401, "Wrong password")
@@ -781,7 +786,7 @@ async def submit_form(request: Request, token: str):
except HTTPException:
raise
except Exception:
pass
logger.exception("submit_form")
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
cur = conn.execute(
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
@@ -808,12 +813,12 @@ async def submit_form(request: Request, token: str):
except Exception:
continue
except Exception:
pass
logger.exception("submit_form")
try:
from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception:
pass
logger.exception("submit_form")
if "application/json" in ctype:
return {"status": "ok", "row_id": row_id,
"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:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception:
pass
logger.exception("clip_page")
clip_data = {
"url": url,
"title": title[:200],
@@ -203,7 +203,7 @@ async def clip_page(request: Request):
try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception:
pass
logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@@ -249,7 +249,7 @@ async def extensions_page(request: Request):
devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices)
except Exception:
pass
logger.exception("extensions_page")
content_html = f"""
<style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
+5 -5
View File
@@ -160,7 +160,7 @@ async def add_comment(request: Request, page_id: int):
try:
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
except Exception:
pass
logger.exception("add_comment")
return {"id": cur.lastrowid, "status": "created"}
@@ -180,7 +180,7 @@ async def update_comment(request: Request, comment_id: int):
try:
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
except Exception:
pass
logger.exception("update_comment")
return {"status": "updated"}
@@ -246,7 +246,7 @@ async def add_favorite(request: Request):
try:
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
except Exception:
pass
logger.exception("add_favorite")
return {"status": "favorited"}
@@ -493,7 +493,7 @@ async def create_sprint(request: Request, collection_id: int):
try:
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
except Exception:
pass
logger.exception("create_sprint")
return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -523,7 +523,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
try:
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
except Exception:
pass
logger.exception("update_sprint")
return {"id": sid, "status": "updated"}
+8 -5
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import time
from datetime import UTC, datetime
from typing import Any
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None:
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception:
pass
logger.exception("row_to_dict")
return d
# ── Pagination ────────────────────────────────────────────────────────────
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
if dt.timestamp() < time.time():
return None
except Exception:
pass
logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
d = dict(u)
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
except Exception:
pass
logger.exception("resolve_bearer_token")
return d
# 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
@@ -257,7 +260,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
)
conn.commit()
except Exception:
pass
logger.exception("audit_log")
# ── Rate limit per token (in-memory) ─────────────────────────────────────
@@ -307,4 +310,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
)
conn.commit()
except Exception:
pass
logger.exception("store_idempotency")
+3 -3
View File
@@ -24,7 +24,7 @@ import asyncio
import json
import logging
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
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()
if not expr:
return False
now = now or datetime.utcnow()
now = now or datetime.now(UTC).replace(tzinfo=None)
minute = now.minute
fields = expr.split()
@@ -488,7 +488,7 @@ async def automation_scheduler():
from app.services.workers import run_due_workers
await run_due_workers()
except Exception: # noqa: BLE001
logger.debug("worker cron iteration failed")
logger.warning("worker cron iteration failed")
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
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:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
logger.exception("backup_db")
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
+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
logger.debug("calendar sync link %s failed: %s", link_id, exc)
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)
+1 -1
View File
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
logger.exception("get_repo_info")
self._set_cache(cache_key, repo_info)
return repo_info
+1 -1
View File
@@ -349,7 +349,7 @@ class RealtimeManager:
try:
await conn.ws.close(code=4413)
except Exception:
pass
logger.exception("_evict_slow")
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
+1 -1
View File
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
)
conn.commit()
except Exception:
pass
logger.exception("purge_stale_requests")
+2 -1
View File
@@ -17,6 +17,7 @@ import json
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid))
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
+2 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.db import get_conn
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
+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.
7. ✅ **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. ✅ **Recherche FTS** (`/api/v2/search`, repli LIKE).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` généré (402 chemins).
9. ✅ **OpenAPI** : `/docs` + `/redoc` activés, `docs/openapi-v2.json` régénéré à chaque bump (511 chemins, `info.version 7.3.9`).
10. ✅ **Tests** (`tests/test_public_api_v2.py`) : **24 tests** — auth scopes, CRUD par ressource, pagination, RFC 7807, idempotence, webhooks, search, admin.
11. ✅ **Documentation** : `ROADMAP.md`, `CHANGELOG.md`, ce guide + `/help`.
+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.*
pydantic==2.10.*
pydantic-settings==2.7.*
aiosqlite==0.20.*
loguru==0.7.*
python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.*
slowapi==0.1.*
weasyprint==69.0
xhtml2pdf==0.2.*
openpyxl==3.1.*
+67
View File
@@ -36,6 +36,35 @@ def test_public_view_hides_restricted_collection(client):
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_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app
@@ -101,6 +130,44 @@ def test_upload_requires_session_and_validates_files(client):
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):
"""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"]
+2 -2
View File
@@ -217,8 +217,8 @@ class TestV54TrashPurge:
with get_conn() as conn:
# insert 2 deleted pages: one old, one recent
old = datetime.datetime.utcnow() - datetime.timedelta(days=45)
recent = datetime.datetime.utcnow() - datetime.timedelta(days=5)
old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45)
recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5)
cur = conn.execute(
"INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)",
("test", "Old Page", old.isoformat()),