From ffa1fa89ab06ad73705630575aa1b78fddbfeda8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 08:16:42 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20A25=20+=20A21=20(partiel)=20=E2=80=94=20?= =?UTF-8?q?plus=20d'exception=20muque,=20transaction=20prot=C3=A9g=C3=A9e?= =?UTF-8?q?=20(v7.3.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A25 — 84 `except Exception: pass/…` → `logger.exception("")` (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 --- CHANGELOG.md | 18 ++++++++ ROADMAP.md | 6 +-- VERSION | 2 +- WORKLOAD.md | 2 +- app/auth/session.py | 2 +- app/db.py | 5 ++ app/main.py | 2 +- app/migrations.py | 2 +- app/routers/api.py | 4 +- app/routers/api_v2.py | 79 +++++++++++++++----------------- app/routers/auth.py | 2 +- app/routers/board.py | 14 +++--- app/routers/collaboration.py | 6 +-- app/routers/dashboard.py | 20 ++++---- app/routers/realtime.py | 2 +- app/routers/security.py | 2 +- app/routers/sharing.py | 6 +-- app/routers/sites.py | 15 ++++-- app/routers/web_clipper.py | 6 +-- app/routers/workspace.py | 10 ++-- app/services/api_v2_helpers.py | 13 ++++-- app/services/backup.py | 2 +- app/services/github_adapter.py | 2 +- app/services/realtime_server.py | 2 +- app/services/sso_provisioning.py | 2 +- tests/test_audit_p0_fixes.py | 25 ++++++++++ 26 files changed, 151 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8dbf9..bc27657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog - FlowDeck +## 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 diff --git a/ROADMAP.md b/ROADMAP.md index d5c5686..e26fd8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1142,11 +1142,11 @@ Quality DB views, Agent IA Palette → Realtime + E - [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 @@ -1185,4 +1185,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 · **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.** +→ **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.** diff --git a/VERSION b/VERSION index 704726e..8837f2c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.3.7 +7.3.8 diff --git a/WORKLOAD.md b/WORKLOAD.md index 615314a..38a20f1 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.3.7 (audit sécurité — A14 terminé) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.3.8 (audit sécurité — A25 + A21 partiel) | **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 diff --git a/app/auth/session.py b/app/auth/session.py index 5b34467..7478502 100644 --- a/app/auth/session.py +++ b/app/auth/session.py @@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None: ) conn.commit() except Exception: - pass + logger.exception("_touch_session") # FastAPI dependency diff --git a/app/db.py b/app/db.py index 83136d7..b4f9fde 100644 --- a/app/db.py +++ b/app/db.py @@ -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: diff --git a/app/main.py b/app/main.py index 20aab2a..145d042 100644 --- a/app/main.py +++ b/app/main.py @@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.3.7", + version="7.3.8", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 655890f..87e04bb 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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, diff --git a/app/routers/api.py b/app/routers/api.py index ec80f39..4e77f29 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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", diff --git a/app/routers/api_v2.py b/app/routers/api_v2.py index b494d84..751d958 100644 --- a/app/routers/api_v2.py +++ b/app/routers/api_v2.py @@ -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"} diff --git a/app/routers/auth.py b/app/routers/auth.py index 385c4b8..7573088 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -573,4 +573,4 @@ def _log_login(user_id: int, request: Request): ) conn.commit() except Exception: - pass + logger.exception("_log_login") diff --git a/app/routers/board.py b/app/routers/board.py index 2513563..8f54226 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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} diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py index 9f4db4e..057fc11 100644 --- a/app/routers/collaboration.py +++ b/app/routers/collaboration.py @@ -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"} diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 72cc65b..e91eff1 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -52,7 +52,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 +88,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 +179,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 +193,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 +206,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 +771,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 +788,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 +801,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 +942,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": []} @@ -2097,7 +2097,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"]}} diff --git a/app/routers/realtime.py b/app/routers/realtime.py index f1378fe..3b5b3f4 100644 --- a/app/routers/realtime.py +++ b/app/routers/realtime.py @@ -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) diff --git a/app/routers/security.py b/app/routers/security.py index eae128a..bc4167e 100644 --- a/app/routers/security.py +++ b/app/routers/security.py @@ -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"} diff --git a/app/routers/sharing.py b/app/routers/sharing.py index 178456c..3e97176 100644 --- a/app/routers/sharing.py +++ b/app/routers/sharing.py @@ -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, diff --git a/app/routers/sites.py b/app/routers/sites.py index 1ad7a60..625e848 100644 --- a/app/routers/sites.py +++ b/app/routers/sites.py @@ -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 !"} diff --git a/app/routers/web_clipper.py b/app/routers/web_clipper.py index 938babe..476e4d7 100644 --- a/app/routers/web_clipper.py +++ b/app/routers/web_clipper.py @@ -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"""