Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e |
@@ -1,5 +1,37 @@
|
||||
# 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
|
||||
|
||||
- **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
|
||||
|
||||
+4
-4
@@ -1135,18 +1135,18 @@ 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
|
||||
|
||||
@@ -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.**
|
||||
→ **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.**
|
||||
|
||||
+1
-1
@@ -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.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
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ def _touch_session(sid: str) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.3.6",
|
||||
version="7.3.8",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+42
-23
@@ -92,13 +92,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 +112,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 +993,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 +1027,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 +1056,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)
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -573,4 +573,4 @@ def _log_login(user_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_log_login")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -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"]}}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-5
@@ -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 !"}
|
||||
|
||||
@@ -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;}}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -780,4 +780,4 @@ def purge_stale_requests() -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("purge_stale_requests")
|
||||
|
||||
@@ -101,6 +101,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§ion=Private").json()["id"]
|
||||
|
||||
Reference in New Issue
Block a user